update successfull
This commit is contained in:
parent
e7d823d707
commit
5f7af358df
Binary file not shown.
Binary file not shown.
@ -160,6 +160,23 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ACCOUNT_LOGIN_METHODS = ['email']
|
||||||
|
ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*', 'password2*']
|
||||||
|
|
||||||
|
ACCOUNT_UNIQUE_EMAIL = True
|
||||||
|
ACCOUNT_EMAIL_VERIFICATION = 'none'
|
||||||
|
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
|
||||||
|
|
||||||
|
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True
|
||||||
|
|
||||||
|
|
||||||
|
ACCOUNT_FORMS = {'signup': 'recruitment.forms.StaffSignupForm'}
|
||||||
|
|
||||||
|
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||||
|
|
||||||
# Crispy Forms Configuration
|
# Crispy Forms Configuration
|
||||||
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
|
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
|
||||||
CRISPY_TEMPLATE_PACK = "bootstrap5"
|
CRISPY_TEMPLATE_PACK = "bootstrap5"
|
||||||
@ -298,8 +315,8 @@ customColorPalette = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional
|
# CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional
|
||||||
CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional
|
# CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional
|
||||||
CKEDITOR_5_CONFIGS = {
|
CKEDITOR_5_CONFIGS = {
|
||||||
'default': {
|
'default': {
|
||||||
'toolbar': {
|
'toolbar': {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ urlpatterns = [
|
|||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('api/', include(router.urls)),
|
path('api/', include(router.urls)),
|
||||||
path('accounts/', include('allauth.urls')),
|
path('accounts/', include('allauth.urls')),
|
||||||
|
|
||||||
path('i18n/', include('django.conf.urls.i18n')),
|
path('i18n/', include('django.conf.urls.i18n')),
|
||||||
# path('summernote/', include('django_summernote.urls')),
|
# path('summernote/', include('django_summernote.urls')),
|
||||||
# path('', include('recruitment.urls')),
|
# path('', include('recruitment.urls')),
|
||||||
@ -32,6 +33,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
urlpatterns += i18n_patterns(
|
urlpatterns += i18n_patterns(
|
||||||
path('', include('recruitment.urls')),
|
path('', include('recruitment.urls')),
|
||||||
|
|
||||||
)
|
)
|
||||||
# 2. URLs that DO have a language prefix (user-facing views)
|
# 2. URLs that DO have a language prefix (user-facing views)
|
||||||
# This includes the root path (''), which is handled by 'recruitment.urls'
|
# This includes the root path (''), which is handled by 'recruitment.urls'
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -519,4 +519,69 @@ class ProfileImageUploadForm(forms.ModelForm):
|
|||||||
# class UserEditForms(forms.ModelForm):
|
# class UserEditForms(forms.ModelForm):
|
||||||
# class Meta:
|
# class Meta:
|
||||||
# model = User
|
# model = User
|
||||||
# fields = ['first_name', 'last_name']
|
# fields = ['first_name', 'last_name']
|
||||||
|
|
||||||
|
|
||||||
|
from django.contrib.auth.forms import UserCreationForm
|
||||||
|
# class StaffUserCreationForm(UserCreationForm):
|
||||||
|
# email = forms.EmailField(required=True)
|
||||||
|
# first_name = forms.CharField(max_length=30)
|
||||||
|
# last_name = forms.CharField(max_length=150)
|
||||||
|
|
||||||
|
# class Meta:
|
||||||
|
# model = User
|
||||||
|
# fields = ("email", "first_name", "last_name", "password1", "password2")
|
||||||
|
|
||||||
|
# def save(self, commit=True):
|
||||||
|
# user = super().save(commit=False)
|
||||||
|
# user.email = self.cleaned_data["email"]
|
||||||
|
# user.first_name = self.cleaned_data["first_name"]
|
||||||
|
# user.last_name = self.cleaned_data["last_name"]
|
||||||
|
# user.username = self.cleaned_data["email"] # or generate
|
||||||
|
# user.is_staff = True
|
||||||
|
# if commit:
|
||||||
|
# user.save()
|
||||||
|
# return user
|
||||||
|
|
||||||
|
import re
|
||||||
|
class StaffUserCreationForm(UserCreationForm):
|
||||||
|
email = forms.EmailField(required=True)
|
||||||
|
first_name = forms.CharField(max_length=30, required=True)
|
||||||
|
last_name = forms.CharField(max_length=150, required=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ("email", "first_name", "last_name", "password1", "password2")
|
||||||
|
|
||||||
|
def clean_email(self):
|
||||||
|
email = self.cleaned_data["email"]
|
||||||
|
if User.objects.filter(email=email).exists():
|
||||||
|
raise forms.ValidationError("A user with this email already exists.")
|
||||||
|
return email
|
||||||
|
|
||||||
|
def generate_username(self, email):
|
||||||
|
"""Generate a valid, unique username from email."""
|
||||||
|
prefix = email.split('@')[0].lower()
|
||||||
|
username = re.sub(r'[^a-z0-9._]', '', prefix)
|
||||||
|
if not username:
|
||||||
|
username = 'user'
|
||||||
|
base = username
|
||||||
|
counter = 1
|
||||||
|
while User.objects.filter(username=username).exists():
|
||||||
|
username = f"{base}{counter}"
|
||||||
|
counter += 1
|
||||||
|
return username
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
user = super().save(commit=False)
|
||||||
|
user.email = self.cleaned_data["email"]
|
||||||
|
user.first_name = self.cleaned_data["first_name"]
|
||||||
|
user.last_name = self.cleaned_data["last_name"]
|
||||||
|
user.username = self.generate_username(user.email) # never use raw email if it has dots, etc.
|
||||||
|
user.is_staff = True
|
||||||
|
if commit:
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,10 @@ from django.utils import timezone
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Define a constant for the API version for better maintenance
|
||||||
|
LINKEDIN_API_VERSION = '2.0.0'
|
||||||
|
LINKEDIN_VERSION = '202409' # Modern API version for header control
|
||||||
|
|
||||||
class LinkedInService:
|
class LinkedInService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.client_id = settings.LINKEDIN_CLIENT_ID
|
self.client_id = settings.LINKEDIN_CLIENT_ID
|
||||||
@ -79,7 +83,8 @@ class LinkedInService:
|
|||||||
url = f"https://api.linkedin.com/v2/assets/{quote(asset_urn)}"
|
url = f"https://api.linkedin.com/v2/assets/{quote(asset_urn)}"
|
||||||
headers = {
|
headers = {
|
||||||
'Authorization': f'Bearer {self.access_token}',
|
'Authorization': f'Bearer {self.access_token}',
|
||||||
'X-Restli-Protocol-Version': '2.0.0'
|
'X-Restli-Protocol-Version': LINKEDIN_API_VERSION,
|
||||||
|
'LinkedIn-Version': LINKEDIN_VERSION,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -96,7 +101,8 @@ class LinkedInService:
|
|||||||
headers = {
|
headers = {
|
||||||
'Authorization': f'Bearer {self.access_token}',
|
'Authorization': f'Bearer {self.access_token}',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Restli-Protocol-Version': '2.0.0'
|
'X-Restli-Protocol-Version': LINKEDIN_API_VERSION,
|
||||||
|
'LinkedIn-Version': LINKEDIN_VERSION,
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@ -138,9 +144,6 @@ class LinkedInService:
|
|||||||
try:
|
try:
|
||||||
status = self.get_asset_status(asset_urn)
|
status = self.get_asset_status(asset_urn)
|
||||||
if status == "READY" or status == "PROCESSING":
|
if status == "READY" or status == "PROCESSING":
|
||||||
# Exit successfully on READY, but also exit successfully on PROCESSING
|
|
||||||
# if the timeout is short, relying on the final API call to succeed.
|
|
||||||
# However, returning True on READY is safest.
|
|
||||||
if status == "READY":
|
if status == "READY":
|
||||||
logger.info(f"Asset {asset_urn} is READY. Proceeding.")
|
logger.info(f"Asset {asset_urn} is READY. Proceeding.")
|
||||||
return True
|
return True
|
||||||
@ -151,12 +154,10 @@ class LinkedInService:
|
|||||||
time.sleep(self.ASSET_STATUS_INTERVAL)
|
time.sleep(self.ASSET_STATUS_INTERVAL)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If the status check fails for any reason (400, connection, etc.),
|
|
||||||
# we log it, wait a bit longer, and try again, instead of crashing.
|
|
||||||
logger.warning(f"Error during asset status check for {asset_urn}: {e}. Retrying.")
|
logger.warning(f"Error during asset status check for {asset_urn}: {e}. Retrying.")
|
||||||
time.sleep(self.ASSET_STATUS_INTERVAL * 2)
|
time.sleep(self.ASSET_STATUS_INTERVAL * 2)
|
||||||
|
|
||||||
# If the loop times out, force the post anyway (mimicking the successful manual fix)
|
# If the loop times out, return True to attempt post, but log warning
|
||||||
logger.warning(f"Asset {asset_urn} timed out, but upload succeeded. Forcing post attempt.")
|
logger.warning(f"Asset {asset_urn} timed out, but upload succeeded. Forcing post attempt.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -253,9 +254,12 @@ class LinkedInService:
|
|||||||
message_parts.append("\n" + " ".join(hashtags))
|
message_parts.append("\n" + " ".join(hashtags))
|
||||||
|
|
||||||
return "\n".join(message_parts)
|
return "\n".join(message_parts)
|
||||||
|
|
||||||
def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn):
|
def _send_ugc_post(self, person_urn, job_posting, media_category="NONE", media_list=None):
|
||||||
"""Step 3: Creates the final LinkedIn post payload with the image asset."""
|
"""
|
||||||
|
New private method to handle the final UGC post request (text or image).
|
||||||
|
This eliminates the duplication between create_job_post and create_job_post_with_image.
|
||||||
|
"""
|
||||||
|
|
||||||
message = self._build_post_message(job_posting)
|
message = self._build_post_message(job_posting)
|
||||||
|
|
||||||
@ -263,25 +267,24 @@ class LinkedInService:
|
|||||||
headers = {
|
headers = {
|
||||||
'Authorization': f'Bearer {self.access_token}',
|
'Authorization': f'Bearer {self.access_token}',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Restli-Protocol-Version': '2.0.0'
|
'X-Restli-Protocol-Version': LINKEDIN_API_VERSION,
|
||||||
|
'LinkedIn-Version': LINKEDIN_VERSION,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
specific_content = {
|
||||||
|
"com.linkedin.ugc.ShareContent": {
|
||||||
|
"shareCommentary": {"text": message},
|
||||||
|
"shareMediaCategory": media_category,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if media_list:
|
||||||
|
specific_content["com.linkedin.ugc.ShareContent"]["media"] = media_list
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"author": f"urn:li:person:{person_urn}",
|
"author": f"urn:li:person:{person_urn}",
|
||||||
"lifecycleState": "PUBLISHED",
|
"lifecycleState": "PUBLISHED",
|
||||||
"specificContent": {
|
"specificContent": specific_content,
|
||||||
"com.linkedin.ugc.ShareContent": {
|
|
||||||
"shareCommentary": {"text": message},
|
|
||||||
"shareMediaCategory": "IMAGE",
|
|
||||||
"media": [{
|
|
||||||
"status": "READY",
|
|
||||||
"media": asset_urn,
|
|
||||||
"description": {"text": job_posting.title},
|
|
||||||
"originalUrl": job_posting.application_url,
|
|
||||||
"title": {"text": "Apply Now"}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"visibility": {
|
"visibility": {
|
||||||
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
||||||
}
|
}
|
||||||
@ -300,6 +303,28 @@ class LinkedInService:
|
|||||||
'status_code': response.status_code
|
'status_code': response.status_code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn):
|
||||||
|
"""Step 3: Creates the final LinkedIn post payload with the image asset."""
|
||||||
|
|
||||||
|
# Prepare the media list for the _send_ugc_post helper
|
||||||
|
media_list = [{
|
||||||
|
"status": "READY",
|
||||||
|
"media": asset_urn,
|
||||||
|
"description": {"text": job_posting.title},
|
||||||
|
"originalUrl": job_posting.application_url,
|
||||||
|
"title": {"text": "Apply Now"}
|
||||||
|
}]
|
||||||
|
|
||||||
|
# Use the helper method to send the post
|
||||||
|
return self._send_ugc_post(
|
||||||
|
person_urn=person_urn,
|
||||||
|
job_posting=job_posting,
|
||||||
|
media_category="IMAGE",
|
||||||
|
media_list=media_list
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_job_post(self, job_posting):
|
def create_job_post(self, job_posting):
|
||||||
"""Main method to create a job announcement post (Image or Text)."""
|
"""Main method to create a job announcement post (Image or Text)."""
|
||||||
if not self.access_token:
|
if not self.access_token:
|
||||||
@ -346,41 +371,12 @@ class LinkedInService:
|
|||||||
has_image = False
|
has_image = False
|
||||||
|
|
||||||
# === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) ===
|
# === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) ===
|
||||||
message = self._build_post_message(job_posting)
|
# Use the single helper method here
|
||||||
|
return self._send_ugc_post(
|
||||||
url = "https://api.linkedin.com/v2/ugcPosts"
|
person_urn=person_urn,
|
||||||
headers = {
|
job_posting=job_posting,
|
||||||
'Authorization': f'Bearer {self.access_token}',
|
media_category="NONE"
|
||||||
'Content-Type': 'application/json',
|
)
|
||||||
'X-Restli-Protocol-Version': '2.0.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"author": f"urn:li:person:{person_urn}",
|
|
||||||
"lifecycleState": "PUBLISHED",
|
|
||||||
"specificContent": {
|
|
||||||
"com.linkedin.ugc.ShareContent": {
|
|
||||||
"shareCommentary": {"text": message},
|
|
||||||
"shareMediaCategory": "NONE", # Pure text post
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"visibility": {
|
|
||||||
"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:
|
except Exception as e:
|
||||||
logger.error(f"Error creating LinkedIn post: {e}")
|
logger.error(f"Error creating LinkedIn post: {e}")
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-10-12 10:34
|
# Generated by Django 5.2.7 on 2025-10-17 19:41
|
||||||
|
|
||||||
import django.core.validators
|
import django.core.validators
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
@ -105,10 +105,12 @@ class Migration(migrations.Migration):
|
|||||||
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
|
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
|
||||||
('join_url', models.URLField(verbose_name='Join URL')),
|
('join_url', models.URLField(verbose_name='Join URL')),
|
||||||
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
|
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
|
||||||
|
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
|
||||||
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
|
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
|
||||||
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
|
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
|
||||||
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
|
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
|
||||||
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
|
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
|
||||||
|
('status', models.CharField(blank=True, max_length=20, null=True, verbose_name='Status')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'abstract': False,
|
'abstract': False,
|
||||||
@ -185,7 +187,7 @@ class Migration(migrations.Migration):
|
|||||||
('name', models.CharField(help_text='Name of the form template', max_length=200)),
|
('name', models.CharField(help_text='Name of the form template', max_length=200)),
|
||||||
('description', models.TextField(blank=True, help_text='Description of the form template')),
|
('description', models.TextField(blank=True, help_text='Description of the form template')),
|
||||||
('is_active', models.BooleanField(default=False, help_text='Whether this template is active')),
|
('is_active', models.BooleanField(default=False, help_text='Whether this template is active')),
|
||||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL)),
|
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Form Template',
|
'verbose_name': 'Form Template',
|
||||||
@ -217,13 +219,14 @@ class Migration(migrations.Migration):
|
|||||||
('address', models.TextField(max_length=200, verbose_name='Address')),
|
('address', models.TextField(max_length=200, verbose_name='Address')),
|
||||||
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
|
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
|
||||||
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
|
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
|
||||||
|
('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')),
|
||||||
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
|
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
|
||||||
('applied', models.BooleanField(default=False, verbose_name='Applied')),
|
('applied', models.BooleanField(default=False, verbose_name='Applied')),
|
||||||
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], default='Applied', max_length=100, verbose_name='Stage')),
|
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], default='Applied', max_length=100, verbose_name='Stage')),
|
||||||
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')),
|
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')),
|
||||||
('exam_date', models.DateField(blank=True, null=True, verbose_name='Exam Date')),
|
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
|
||||||
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')),
|
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')),
|
||||||
('interview_date', models.DateField(blank=True, null=True, verbose_name='Interview Date')),
|
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
|
||||||
('interview_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Interview Status')),
|
('interview_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Interview Status')),
|
||||||
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
|
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
|
||||||
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')),
|
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')),
|
||||||
@ -257,11 +260,12 @@ class Migration(migrations.Migration):
|
|||||||
('salary_range', models.CharField(blank=True, help_text='e.g., $60,000 - $80,000', max_length=200)),
|
('salary_range', models.CharField(blank=True, help_text='e.g., $60,000 - $80,000', max_length=200)),
|
||||||
('benefits', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
|
('benefits', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
|
||||||
('application_url', models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()])),
|
('application_url', models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()])),
|
||||||
|
('application_start_date', models.DateField(blank=True, null=True)),
|
||||||
('application_deadline', models.DateField(blank=True, null=True)),
|
('application_deadline', models.DateField(blank=True, null=True)),
|
||||||
('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
|
('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
|
||||||
('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)),
|
('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)),
|
||||||
('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
|
('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
|
||||||
('status', models.CharField(blank=True, choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20, null=True)),
|
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20)),
|
||||||
('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
|
('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
|
||||||
('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)),
|
('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)),
|
||||||
('linkedin_post_url', models.URLField(blank=True, help_text='Direct URL to LinkedIn post')),
|
('linkedin_post_url', models.URLField(blank=True, help_text='Direct URL to LinkedIn post')),
|
||||||
@ -271,7 +275,7 @@ class Migration(migrations.Migration):
|
|||||||
('published_at', models.DateTimeField(blank=True, null=True)),
|
('published_at', models.DateTimeField(blank=True, null=True)),
|
||||||
('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)),
|
('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)),
|
||||||
('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)),
|
('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)),
|
||||||
('start_date', models.DateField(blank=True, help_text='Desired start date', null=True)),
|
('joining_date', models.DateField(blank=True, help_text='Desired start date', null=True)),
|
||||||
('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')),
|
('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')),
|
||||||
('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')),
|
('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')),
|
||||||
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
|
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
|
||||||
@ -296,10 +300,10 @@ class Migration(migrations.Migration):
|
|||||||
('working_days', models.JSONField(verbose_name='Working Days')),
|
('working_days', models.JSONField(verbose_name='Working Days')),
|
||||||
('start_time', models.TimeField(verbose_name='Start Time')),
|
('start_time', models.TimeField(verbose_name='Start Time')),
|
||||||
('end_time', models.TimeField(verbose_name='End Time')),
|
('end_time', models.TimeField(verbose_name='End Time')),
|
||||||
|
('breaks', models.JSONField(blank=True, default=list, verbose_name='Break Times')),
|
||||||
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
|
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
|
||||||
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
|
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
('breaks', models.ManyToManyField(blank=True, related_name='schedules', to='recruitment.breaktime')),
|
|
||||||
('candidates', models.ManyToManyField(related_name='interview_schedules', to='recruitment.candidate')),
|
('candidates', models.ManyToManyField(related_name='interview_schedules', to='recruitment.candidate')),
|
||||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
|
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
|
||||||
@ -322,8 +326,8 @@ class Migration(migrations.Migration):
|
|||||||
name='JobPostingImage',
|
name='JobPostingImage',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('post_image', models.ImageField(upload_to='post/')),
|
('post_image', models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size])),
|
||||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')),
|
('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
@ -405,7 +409,7 @@ class Migration(migrations.Migration):
|
|||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')),
|
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')),
|
||||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
|
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
|
||||||
('schedule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
|
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
|
||||||
('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
|
('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-12 10:50
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='jobposting',
|
|
||||||
name='status',
|
|
||||||
field=models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-10-12 12:15
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='candidate',
|
|
||||||
name='is_potential_candidate',
|
|
||||||
field=models.BooleanField(default=False, verbose_name='Potential Candidate'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='jobposting',
|
|
||||||
name='status',
|
|
||||||
field=models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-10-12 15:57
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0002_candidate_is_potential_candidate_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='candidate',
|
|
||||||
name='exam_date',
|
|
||||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Exam Date'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-12 13:18
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0002_alter_jobposting_status'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RenameField(
|
|
||||||
model_name='jobposting',
|
|
||||||
old_name='start_date',
|
|
||||||
new_name='joining_date',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='jobposting',
|
|
||||||
name='application_start_date',
|
|
||||||
field=models.DateField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-10-12 15:57
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0003_alter_candidate_exam_date'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='candidate',
|
|
||||||
name='interview_date',
|
|
||||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Interview Date'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-10-12 21:21
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0004_alter_candidate_interview_date'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='interviewschedule',
|
|
||||||
name='breaks',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='interviewschedule',
|
|
||||||
name='breaks',
|
|
||||||
field=models.JSONField(blank=True, default=list, verbose_name='Break Times'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-10-13 12:00
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0005_remove_interviewschedule_breaks_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='zoommeeting',
|
|
||||||
name='meeting_status',
|
|
||||||
field=models.CharField(choices=[('scheduled', 'Scheduled'), ('started', 'Started'), ('ended', 'Ended')], default='scheduled', max_length=20, verbose_name='Meeting Status'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-10-13 12:20
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0006_zoommeeting_meeting_status'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='zoommeeting',
|
|
||||||
name='meeting_status',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='zoommeeting',
|
|
||||||
name='status',
|
|
||||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Status'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-10-13 12:22
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0007_remove_zoommeeting_meeting_status_zoommeeting_status'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='zoommeeting',
|
|
||||||
name='password',
|
|
||||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Password'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-10-13 14:14
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0003_rename_start_date_jobposting_joining_date_and_more'),
|
|
||||||
('recruitment', '0008_zoommeeting_password'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
]
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-13 14:18
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0003_rename_start_date_jobposting_joining_date_and_more'),
|
|
||||||
('recruitment', '0008_zoommeeting_password'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
]
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-10-13 19:55
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0009_merge_20251013_1714'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='scheduledinterview',
|
|
||||||
name='schedule',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-13 15:19
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0009_merge_20251013_1714'),
|
|
||||||
('recruitment', '0009_merge_20251013_1718'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
]
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-13 22:16
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import recruitment.validators
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0010_merge_20251013_1819'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='jobpostingimage',
|
|
||||||
name='job',
|
|
||||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='jobpostingimage',
|
|
||||||
name='post_image',
|
|
||||||
field=models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size]),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-10-14 11:03
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0010_alter_scheduledinterview_schedule'),
|
|
||||||
('recruitment', '0011_alter_jobpostingimage_job_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
]
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-10-14 11:24
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('recruitment', '0012_merge_20251014_1403'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='formtemplate',
|
|
||||||
name='created_by',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -4,6 +4,11 @@ import logging
|
|||||||
import requests
|
import requests
|
||||||
from PyPDF2 import PdfReader
|
from PyPDF2 import PdfReader
|
||||||
from recruitment.models import Candidate
|
from recruitment.models import Candidate
|
||||||
|
from . linkedin_service import LinkedInService
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from . models import JobPosting
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -153,3 +158,39 @@ def handle_reume_parsing_and_scoring(pk):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to score resume for candidate {instance.id}: {e}")
|
logger.error(f"Failed to score resume for candidate {instance.id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def linkedin_post_task(job_slug, access_token):
|
||||||
|
# for linked post background tasks
|
||||||
|
|
||||||
|
job=get_object_or_404(JobPosting,slug=job_slug)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service=LinkedInService()
|
||||||
|
service.access_token=access_token
|
||||||
|
# long running task
|
||||||
|
result=service.create_job_post(job)
|
||||||
|
|
||||||
|
#update the jobposting object with the final result
|
||||||
|
if result['success']:
|
||||||
|
job.posted_to_linkedin=True
|
||||||
|
job.linkedin_post_id=result['post_id']
|
||||||
|
job.linkedin_post_url=result['post_url']
|
||||||
|
job.linkedin_post_status='SUCCESSS'
|
||||||
|
job.linkedin_posted_at=timezone.now()
|
||||||
|
else:
|
||||||
|
error_msg=result.get('error',"Unknown API error")
|
||||||
|
job.linkedin_post_status = 'FAILED'
|
||||||
|
logger.error(f"LinkedIn post failed for job {job_slug}: {error_msg}")
|
||||||
|
job.save()
|
||||||
|
return result['success']
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Critical error in LinkedIn task for job {job_slug}: {e}", exc_info=True)
|
||||||
|
# Update job status with the critical error
|
||||||
|
job.linkedin_post_status = f"CRITICAL_ERROR: {str(e)}"
|
||||||
|
job.save()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -109,6 +109,9 @@ urlpatterns = [
|
|||||||
# users urls
|
# users urls
|
||||||
path('user/<int:pk>',views.user_detail,name='user_detail'),
|
path('user/<int:pk>',views.user_detail,name='user_detail'),
|
||||||
path('user/user_profile_image_update/<int:pk>',views.user_profile_image_update,name='user_profile_image_update'),
|
path('user/user_profile_image_update/<int:pk>',views.user_profile_image_update,name='user_profile_image_update'),
|
||||||
path('easy_logs/',views.easy_logs,name='easy_logs')
|
path('easy_logs/',views.easy_logs,name='easy_logs'),
|
||||||
|
path('settings/',views.admin_settings,name='admin_settings'),
|
||||||
|
path('staff/create',views.create_staff_user,name='create_staff_user'),
|
||||||
|
path('set_staff_password/<int:pk>/',views.set_staff_password,name='set_staff_password')
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
def validate_image_size(image):
|
def validate_image_size(image):
|
||||||
max_size_mb = 2
|
max_size_mb = 1
|
||||||
if image.size > max_size_mb * 1024 * 1024:
|
if image.size > max_size_mb * 1024 * 1024:
|
||||||
raise ValidationError(f"Image size should not exceed {max_size_mb}MB.")
|
raise ValidationError(f"Image size should not exceed {max_size_mb}MB.")
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ from .forms import (
|
|||||||
BreakTimeFormSet,
|
BreakTimeFormSet,
|
||||||
JobPostingImageForm,
|
JobPostingImageForm,
|
||||||
ProfileImageUploadForm,
|
ProfileImageUploadForm,
|
||||||
|
StaffUserCreationForm
|
||||||
|
|
||||||
)
|
)
|
||||||
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
|
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
|
||||||
@ -56,7 +57,8 @@ from .models import (
|
|||||||
Candidate,
|
Candidate,
|
||||||
JobPosting,
|
JobPosting,
|
||||||
ScheduledInterview,
|
ScheduledInterview,
|
||||||
JobPostingImage
|
JobPostingImage,
|
||||||
|
Profile
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
from datastar_py.django import (
|
from datastar_py.django import (
|
||||||
@ -321,8 +323,12 @@ def job_detail(request, slug):
|
|||||||
|
|
||||||
|
|
||||||
status_form = JobPostingStatusForm(instance=job)
|
status_form = JobPostingStatusForm(instance=job)
|
||||||
image_upload_form=JobPostingImageForm(instance=job.post_images)
|
try:
|
||||||
|
# If the related object exists, use its instance data
|
||||||
|
image_upload_form = JobPostingImageForm(instance=job.post_images)
|
||||||
|
except Exception as e:
|
||||||
|
# If the related object does NOT exist, create a blank form
|
||||||
|
image_upload_form = JobPostingImageForm()
|
||||||
|
|
||||||
|
|
||||||
# 2. Check for POST request (Status Update Submission)
|
# 2. Check for POST request (Status Update Submission)
|
||||||
@ -409,6 +415,8 @@ def job_detail_candidate(request, slug):
|
|||||||
return render(request, "forms/job_detail_candidate.html", {"job": job})
|
return render(request, "forms/job_detail_candidate.html", {"job": job})
|
||||||
|
|
||||||
|
|
||||||
|
from django_q.tasks import async_task
|
||||||
|
|
||||||
def post_to_linkedin(request, slug):
|
def post_to_linkedin(request, slug):
|
||||||
"""Post a job to LinkedIn"""
|
"""Post a job to LinkedIn"""
|
||||||
job = get_object_or_404(JobPosting, slug=slug)
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
@ -417,47 +425,39 @@ def post_to_linkedin(request, slug):
|
|||||||
return redirect("job_list")
|
return redirect("job_list")
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
try:
|
linkedin_access_token=request.session.get("linkedin_access_token")
|
||||||
# Check if user is authenticated with LinkedIn
|
# Check if user is authenticated with LinkedIn
|
||||||
if "linkedin_access_token" not in request.session:
|
if not "linkedin_access_token":
|
||||||
messages.error(request, "Please authenticate with LinkedIn first.")
|
messages.error(request, "Please authenticate with LinkedIn first.")
|
||||||
return redirect("linkedin_login")
|
return redirect("linkedin_login")
|
||||||
|
try:
|
||||||
|
|
||||||
# Clear previous LinkedIn data for re-posting
|
# Clear previous LinkedIn data for re-posting
|
||||||
|
#Prepare the job object for background processing
|
||||||
job.posted_to_linkedin = False
|
job.posted_to_linkedin = False
|
||||||
job.linkedin_post_id = ""
|
job.linkedin_post_id = ""
|
||||||
job.linkedin_post_url = ""
|
job.linkedin_post_url = ""
|
||||||
job.linkedin_post_status = ""
|
job.linkedin_post_status = "QUEUED"
|
||||||
job.linkedin_posted_at = None
|
job.linkedin_posted_at = None
|
||||||
job.save()
|
job.save()
|
||||||
|
|
||||||
|
# ENQUEUE THE TASK
|
||||||
|
# Pass the function path, the job slug, and the token as arguments
|
||||||
|
|
||||||
# Initialize LinkedIn service
|
async_task(
|
||||||
service = LinkedInService()
|
'recruitment.tasks.linkedin_post_task',
|
||||||
service.access_token = request.session["linkedin_access_token"]
|
job.slug,
|
||||||
|
linkedin_access_token
|
||||||
|
)
|
||||||
|
|
||||||
# Post to LinkedIn
|
messages.success(
|
||||||
result = service.create_job_post(job)
|
request,
|
||||||
if result["success"]:
|
_(f"✅ Job posting process for job with JOB ID: {job.internal_job_id} started! Check the job details page in a moment for the final status.")
|
||||||
# Update job with LinkedIn info
|
)
|
||||||
job.posted_to_linkedin = True
|
|
||||||
job.linkedin_post_id = result["post_id"]
|
|
||||||
job.linkedin_post_url = result["post_url"]
|
|
||||||
job.linkedin_post_status = "SUCCESS"
|
|
||||||
job.linkedin_posted_at = timezone.now()
|
|
||||||
job.save()
|
|
||||||
|
|
||||||
messages.success(request, "Job posted to LinkedIn successfully!")
|
|
||||||
else:
|
|
||||||
error_msg = result.get("error", "Unknown error")
|
|
||||||
job.linkedin_post_status = f"ERROR: {error_msg}"
|
|
||||||
job.save()
|
|
||||||
messages.error(request, f"Error posting to LinkedIn: {error_msg}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in post_to_linkedin: {e}")
|
logger.error(f"Error enqueuing LinkedIn post: {e}")
|
||||||
job.linkedin_post_status = f"ERROR: {str(e)}"
|
messages.error(request, _("Failed to start the job posting process. Please try again."))
|
||||||
job.save()
|
|
||||||
messages.error(request, f"Error posting to LinkedIn: {e}")
|
|
||||||
|
|
||||||
return redirect("job_detail", slug=job.slug)
|
return redirect("job_detail", slug=job.slug)
|
||||||
|
|
||||||
@ -2355,9 +2355,15 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
|
||||||
def user_profile_image_update(request, pk):
|
def user_profile_image_update(request, pk):
|
||||||
user = get_object_or_404(User, pk=pk)
|
user = get_object_or_404(User, pk=pk)
|
||||||
|
try:
|
||||||
|
instance =user.profile
|
||||||
|
|
||||||
|
except ObjectDoesNotExist as e:
|
||||||
|
Profile.objects.create(user=user)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
profile_form = ProfileImageUploadForm(request.POST, request.FILES, instance=user.profile)
|
profile_form = ProfileImageUploadForm(request.POST, request.FILES, instance=user.profile)
|
||||||
@ -2380,7 +2386,9 @@ def user_profile_image_update(request, pk):
|
|||||||
|
|
||||||
def user_detail(request, pk):
|
def user_detail(request, pk):
|
||||||
user = get_object_or_404(User, pk=pk)
|
user = get_object_or_404(User, pk=pk)
|
||||||
profile_form = ProfileImageUploadForm(instance=user.profile)
|
|
||||||
|
|
||||||
|
profile_form = ProfileImageUploadForm()
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
first_name=request.POST.get('first_name')
|
first_name=request.POST.get('first_name')
|
||||||
last_name=request.POST.get('last_name')
|
last_name=request.POST.get('last_name')
|
||||||
@ -2406,7 +2414,7 @@ def easy_logs(request):
|
|||||||
"""
|
"""
|
||||||
logs_per_page = 20
|
logs_per_page = 20
|
||||||
|
|
||||||
# 1. Determine the active tab and the corresponding model/queryset
|
|
||||||
active_tab = request.GET.get('tab', 'crud')
|
active_tab = request.GET.get('tab', 'crud')
|
||||||
|
|
||||||
if active_tab == 'login':
|
if active_tab == 'login':
|
||||||
@ -2415,23 +2423,23 @@ def easy_logs(request):
|
|||||||
elif active_tab == 'request':
|
elif active_tab == 'request':
|
||||||
queryset = RequestEvent.objects.order_by('-datetime')
|
queryset = RequestEvent.objects.order_by('-datetime')
|
||||||
tab_title = _("HTTP Requests")
|
tab_title = _("HTTP Requests")
|
||||||
else: # Default is 'crud'
|
else:
|
||||||
queryset = CRUDEvent.objects.order_by('-datetime')
|
queryset = CRUDEvent.objects.order_by('-datetime')
|
||||||
tab_title = _("Model Changes (CRUD)")
|
tab_title = _("Model Changes (CRUD)")
|
||||||
active_tab = 'crud'
|
active_tab = 'crud'
|
||||||
|
|
||||||
# 2. Apply Pagination
|
|
||||||
paginator = Paginator(queryset, logs_per_page)
|
paginator = Paginator(queryset, logs_per_page)
|
||||||
page = request.GET.get('page')
|
page = request.GET.get('page')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get the page object for the requested page number
|
|
||||||
logs_page = paginator.page(page)
|
logs_page = paginator.page(page)
|
||||||
except PageNotAnInteger:
|
except PageNotAnInteger:
|
||||||
# If page is not an integer, deliver first page.
|
|
||||||
logs_page = paginator.page(1)
|
logs_page = paginator.page(1)
|
||||||
except EmptyPage:
|
except EmptyPage:
|
||||||
# If page is out of range, deliver last page of results.
|
|
||||||
logs_page = paginator.page(paginator.num_pages)
|
logs_page = paginator.page(paginator.num_pages)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
@ -2443,3 +2451,63 @@ def easy_logs(request):
|
|||||||
|
|
||||||
return render(request, "includes/easy_logs.html", context)
|
return render(request, "includes/easy_logs.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from allauth.account.views import SignupView
|
||||||
|
from django.contrib.auth.decorators import user_passes_test
|
||||||
|
|
||||||
|
def is_superuser_check(user):
|
||||||
|
return user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
@user_passes_test(is_superuser_check)
|
||||||
|
def create_staff_user(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
|
||||||
|
form = StaffUserCreationForm(request.POST)
|
||||||
|
print(form)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Staff user {form.cleaned_data['first_name']} {form.cleaned_data['last_name']} "
|
||||||
|
f"({form.cleaned_data['email']}) created successfully!"
|
||||||
|
)
|
||||||
|
return redirect('admin_settings')
|
||||||
|
else:
|
||||||
|
form = StaffUserCreationForm()
|
||||||
|
return render(request, 'user/create_staff.html', {'form': form})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@user_passes_test(is_superuser_check)
|
||||||
|
def admin_settings(request):
|
||||||
|
staffs=User.objects.filter(is_superuser=False)
|
||||||
|
context={
|
||||||
|
'staffs':staffs
|
||||||
|
}
|
||||||
|
return render(request,'user/admin_settings.html',context)
|
||||||
|
|
||||||
|
|
||||||
|
from django.contrib.auth.forms import SetPasswordForm
|
||||||
|
|
||||||
|
|
||||||
|
def set_staff_password(request,pk):
|
||||||
|
user=get_object_or_404(User,pk=pk)
|
||||||
|
print(request.POST)
|
||||||
|
if request.method=='POST':
|
||||||
|
form = SetPasswordForm(user, data=request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request,f'Password successfully changed')
|
||||||
|
else:
|
||||||
|
form=SetPasswordForm(user=user)
|
||||||
|
messages.error(request,f'Password does not match please try again.')
|
||||||
|
return redirect('set_staff_password',user=user)
|
||||||
|
|
||||||
|
else:
|
||||||
|
form=SetPasswordForm(user=user)
|
||||||
|
return render(request,'user/staff_password_create.html',{'form':form,'user':user})
|
||||||
@ -142,4 +142,5 @@ PyMuPDF
|
|||||||
pytesseract
|
pytesseract
|
||||||
Pillow
|
Pillow
|
||||||
python-dotenv
|
python-dotenv
|
||||||
django-countries
|
django-countries
|
||||||
|
django-q2
|
||||||
@ -145,10 +145,14 @@
|
|||||||
<li><hr class="dropdown-divider my-1"></li>
|
<li><hr class="dropdown-divider my-1"></li>
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="{% url 'user_detail' request.user.pk %}"><i class="fas fa-user-circle me-3 text-primary fs-5"></i> <span>{% trans "My Profile" %}</span></a></li>
|
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="{% url 'user_detail' request.user.pk %}"><i class="fas fa-user-circle me-3 text-primary fs-5"></i> <span>{% trans "My Profile" %}</span></a></li>
|
||||||
|
|
||||||
|
|
||||||
|
{% if request.user.is_superuser %}
|
||||||
|
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="{% url 'admin_settings' %}"><i class="fas fa-cog me-3 text-primary fs-5"></i> <span>{% trans "Settings" %}</span></a></li>
|
||||||
|
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="{% url 'easy_logs' %}"><i class="fas fa-history me-3 text-primary fs-5"></i> <span>{% trans "Activity Log" %}</span></a></li>
|
||||||
|
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="#"><i class="fas fa-question-circle me-3 text-primary fs-5"></i> <span>{% trans "Help & Support" %}</span></a></li>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="#"><i class="fas fa-cog me-3 text-primary fs-5"></i> <span>{% trans "Settings" %}</span></a></li>
|
|
||||||
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="{% url 'easy_logs' %}"><i class="fas fa-history me-3 text-primary fs-5"></i> <span>{% trans "Activity Log" %}</span></a></li>
|
|
||||||
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="#"><i class="fas fa-question-circle me-3 text-primary fs-5"></i> <span>{% trans "Help & Support" %}</span></a></li>
|
|
||||||
|
|
||||||
{% comment %} CORRECTED LINKEDIN BLOCK {% endcomment %}
|
{% comment %} CORRECTED LINKEDIN BLOCK {% endcomment %}
|
||||||
{% if not request.session.linkedin_authenticated %}
|
{% if not request.session.linkedin_authenticated %}
|
||||||
@ -252,7 +256,7 @@
|
|||||||
<main class="container-fluid flex-grow-1" style="max-width: 1600px; margin: 0 auto;">
|
<main class="container-fluid flex-grow-1" style="max-width: 1600px; margin: 0 auto;">
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mt-2" role="alert">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% trans 'Close' %}"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% trans 'Close' %}"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -80,6 +80,12 @@
|
|||||||
color: var(--kaauh-teal);
|
color: var(--kaauh-teal);
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
}
|
}
|
||||||
|
/* Primary Color Overrides */
|
||||||
|
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
||||||
|
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
|
||||||
|
.text-success { color: var(--kaauh-success) !important; }
|
||||||
|
.text-danger { color: var(--kaauh-danger) !important; }
|
||||||
|
.text-info { color: #17a2b8 !important; }
|
||||||
|
|
||||||
/* Status Badges (Standardized) */
|
/* Status Badges (Standardized) */
|
||||||
.status-badge {
|
.status-badge {
|
||||||
@ -207,7 +213,7 @@
|
|||||||
<div class="card meeting-card h-100 shadow-sm">
|
<div class="card meeting-card h-100 shadow-sm">
|
||||||
<div class="card-body d-flex flex-column">
|
<div class="card-body d-flex flex-column">
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<h5 class="card-title flex-grow-1 me-3">{{ meeting.topic }}</h5>
|
<h5 class="card-title flex-grow-1 me-3"><a href="{% url 'meeting_details' meeting.slug %}" class="text-decoration-none text-primary-theme">{{ meeting.topic }}</a></h5>
|
||||||
<span class="status-badge bg-{{ meeting.status }}">
|
<span class="status-badge bg-{{ meeting.status }}">
|
||||||
{{ meeting.status|title }}
|
{{ meeting.status|title }}
|
||||||
</span>
|
</span>
|
||||||
@ -265,7 +271,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for meeting in meetings %}
|
{% for meeting in meetings %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong class="text-primary">{{ meeting.topic }}</strong></td>
|
<td><strong class="text-primary"><a href="{% url 'meeting_details' meeting.slug %}" class="text-decoration-none text-secondary">{{ meeting.topic }}<a></strong></td>
|
||||||
<td>{{ meeting.meeting_id|default:meeting.id }}</td>
|
<td>{{ meeting.meeting_id|default:meeting.id }}</td>
|
||||||
<td>{{ meeting.start_time|date:"M d, Y H:i" }}</td>
|
<td>{{ meeting.start_time|date:"M d, Y H:i" }}</td>
|
||||||
<td>{{ meeting.duration }} min</td>
|
<td>{{ meeting.duration }} min</td>
|
||||||
|
|||||||
265
templates/user/admin_settings.html
Normal file
265
templates/user/admin_settings.html
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Admin Settings" %} - KAAUH ATS{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<style>
|
||||||
|
/* Theme Variables for Consistency */
|
||||||
|
:root {
|
||||||
|
--color-primary: #007a88; /* Main Teal */
|
||||||
|
--color-primary-dark: #004d55; /* Dark Teal */
|
||||||
|
--color-background-light: #f4f6f9; /* Light Gray background */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout & Card Styling */
|
||||||
|
.container-fluid {
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-top: 3rem;
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
}
|
||||||
|
.dashboard-header {
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin Feature Card Styling */
|
||||||
|
.feature-card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
height: 100%; /* Important for grid consistency */
|
||||||
|
}
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent Colors */
|
||||||
|
.text-accent {
|
||||||
|
color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
.feature-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.feature-title {
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Specific Styling */
|
||||||
|
.table-card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.table-striped > tbody > tr:nth-of-type(odd) > * {
|
||||||
|
background-color: rgba(0, 122, 136, 0.03); /* Light teal stripe */
|
||||||
|
}
|
||||||
|
.page-link {
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
.page-item.active .page-link {
|
||||||
|
background-color: var(--color-primary) !important;
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row px-lg-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1 class="h3 fw-bold dashboard-header">
|
||||||
|
<i class="fas fa-cogs me-2 text-accent"></i>{% trans "Admin Settings Dashboard" %}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# --- User Management Section (Cards) --- #}
|
||||||
|
<div class="row px-lg-4 mb-5">
|
||||||
|
<div class="col-12">
|
||||||
|
<h4 class="text-secondary fw-bold mb-3">{% trans "User & Access Management" %}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 1. Manage/Update Users (Detail Page) - Placeholder link for the table below #}
|
||||||
|
<div class="col-md-6 col-lg-4 mb-4">
|
||||||
|
<a href="#user-list" class="text-decoration-none d-block">
|
||||||
|
<div class="feature-card p-4 text-center">
|
||||||
|
<i class="fas fa-users feature-icon"></i>
|
||||||
|
<h5 class="feature-title">{% trans "View Staff List" %}</h5>
|
||||||
|
<p class="text-muted small mb-0">{% trans "Scroll down to view and manage the paginated list of all staff users." %}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 2. Create User #}
|
||||||
|
<div class="col-md-6 col-lg-4 mb-4">
|
||||||
|
<a href="{% url 'create_staff_user' %}" class="text-decoration-none d-block">
|
||||||
|
<div class="feature-card p-4 text-center">
|
||||||
|
<i class="fas fa-user-plus feature-icon"></i>
|
||||||
|
<h5 class="feature-title">{% trans "Create New User" %}</h5>
|
||||||
|
<p class="text-muted small mb-0">{% trans "Quickly add a new staff member to the Applicant Tracking System." %}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 3. Change Password (Centralized/Admin-forced) #}
|
||||||
|
<div class="col-md-6 col-lg-4 mb-4">
|
||||||
|
<a href="#" class="text-decoration-none d-block">
|
||||||
|
<div class="feature-card p-4 text-center">
|
||||||
|
<i class="fas fa-key feature-icon"></i>
|
||||||
|
<h5 class="feature-title">{% trans "Reset User Passwords" %}</h5>
|
||||||
|
<p class="text-muted small mb-0">{% trans "Enforce password resets or change passwords for existing users." %}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# --- Paged User Table Section --- #}
|
||||||
|
<div class="row px-lg-4" id="user-list">
|
||||||
|
<div class="col-12">
|
||||||
|
<h4 class="text-secondary fw-bold mb-3">{% trans "Staff User List" %}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 table-card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
{# Assumes 'page_obj' contains the paginated queryset from the view #}
|
||||||
|
<table class="table table-striped table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "ID" %}</th>
|
||||||
|
<th>{% trans "Username" %}</th>
|
||||||
|
<th>{% trans "Full Name" %}</th>
|
||||||
|
<th>{% trans "Email" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th class="text-center">{% trans "Actions" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in staffs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.pk }}</td>
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>{{ user.get_full_name|default:user.first_name }}</td>
|
||||||
|
<td>{{ user.email }}</td>
|
||||||
|
<td>
|
||||||
|
{% if user.is_active %}
|
||||||
|
<span class="badge rounded-pill text-bg-success">{% trans "Active" %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge rounded-pill text-bg-secondary">{% trans "Inactive" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center text-nowrap">
|
||||||
|
|
||||||
|
{# 1. Edit Button (Pencil Icon) #}
|
||||||
|
<a href="#" class="btn btn-sm btn-outline-secondary me-1" title="{% trans 'Edit User' %}">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{# 2. Change Password Button (Key Icon) #}
|
||||||
|
{# NOTE: You must define a URL named 'user_password_change' that accepts the user ID #}
|
||||||
|
<a href="{% url 'set_staff_password' user.pk %}" class="btn btn-sm btn-outline-info me-1" title="{% trans 'Change Password' %}">
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{# 3. Delete Button (Trash Icon) #}
|
||||||
|
{# NOTE: You must define a URL named 'user_delete' that accepts the user ID #}
|
||||||
|
<a href="#" class="btn btn-sm btn-outline-danger" title="{% trans 'Delete User' %}">
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center text-muted">{% trans "No staff users found." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# --- Pagination Controls --- #}
|
||||||
|
{% if page_obj.has_other_pages %}
|
||||||
|
<nav class="mt-4" aria-label="Page navigation">
|
||||||
|
<ul class="pagination justify-content-center mb-0">
|
||||||
|
|
||||||
|
{# Previous Button #}
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}" aria-label="Previous">
|
||||||
|
<span aria-hidden="true">«</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">«</span></li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Page Numbers #}
|
||||||
|
{% for i in page_obj.paginator.page_range %}
|
||||||
|
{% if page_obj.number == i %}
|
||||||
|
<li class="page-item active" aria-current="page"><span class="page-link">{{ i }}</span></li>
|
||||||
|
{% elif i > page_obj.number|add:'-3' and i < page_obj.number|add:'3' %}
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ i }}">{{ i }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Next Button #}
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}" aria-label="Next">
|
||||||
|
<span aria-hidden="true">»</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">»</span></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# --- Permissions & Group Management Section --- #}
|
||||||
|
<div class="row px-lg-4 mt-5">
|
||||||
|
<div class="col-12">
|
||||||
|
<h4 class="text-secondary fw-bold mb-3">{% trans "Role & Permissions" %}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 4. Manage Groups #}
|
||||||
|
<div class="col-md-6 col-lg-4 mb-4">
|
||||||
|
<a href="#" class="text-decoration-none d-block">
|
||||||
|
<div class="feature-card p-4 text-center">
|
||||||
|
<i class="fas fa-layer-group feature-icon"></i>
|
||||||
|
<h5 class="feature-title">{% trans "Manage User Groups" %}</h5>
|
||||||
|
<p class="text-muted small mb-0">{% trans "Edit, create, and assign logical permission groups (e.g., HR, Manager)." %}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 5. Manage Permissions #}
|
||||||
|
<div class="col-md-6 col-lg-4 mb-4">
|
||||||
|
<a href="#" class="text-decoration-none d-block">
|
||||||
|
<div class="feature-card p-4 text-center">
|
||||||
|
<i class="fas fa-shield-alt feature-icon"></i>
|
||||||
|
<h5 class="feature-title">{% trans "View Permissions" %}</h5>
|
||||||
|
<p class="text-muted small mb-0">{% trans "Review all available content-level permissions in the system." %}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
81
templates/user/create_staff.html
Normal file
81
templates/user/create_staff.html
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Create Staff User" %} - KAAUH ATS{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<style>
|
||||||
|
/* Custom styles for card and text accent */
|
||||||
|
.form-card {
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 2.5rem;
|
||||||
|
}
|
||||||
|
.text-accent {
|
||||||
|
color: #007a88 !important; /* Teal accent color */
|
||||||
|
}
|
||||||
|
.text-accent:hover {
|
||||||
|
color: #004d55 !important; /* Darker teal hover */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Removed aggressive !important button overrides from here */
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex vh-80 w-100 justify-content-center align-items-center mt-4">
|
||||||
|
|
||||||
|
<div class="form-card">
|
||||||
|
|
||||||
|
<h2 id="form-title" class="h3 fw-bold mb-4 text-center">
|
||||||
|
<i class="fas fa-user-plus me-2 text-accent"></i>{% trans "Create Staff User" %}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags|default:'info' }} p-3 small" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" class="space-y-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|crispy }}
|
||||||
|
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger p-3 small mt-3" role="alert">
|
||||||
|
{% for error in form.non_field_errors %}{{ error }}{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# 🚀 GUARANTEED FIX: Using inline CSS variables to override Bootstrap Primary color #}
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-primary w-100 mt-3"
|
||||||
|
style="--bs-btn-bg: #007a88;
|
||||||
|
--bs-btn-border-color: #007a88;
|
||||||
|
--bs-btn-hover-bg: #004d55;
|
||||||
|
--bs-btn-hover-border-color: #004d55;
|
||||||
|
--bs-btn-active-bg: #004d55;
|
||||||
|
--bs-btn-active-border-color: #004d55;
|
||||||
|
--bs-btn-focus-shadow-rgb: 40, 167, 69;
|
||||||
|
--bs-btn-color: #ffffff;">
|
||||||
|
<i class="fas fa-save me-2"></i>{% trans "Create Staff User" %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="pt-5 mt-2 text-center border-top border-light-subtle">
|
||||||
|
<p class="text-muted small mb-0">
|
||||||
|
<i class="fas fa-arrow-left me-1"></i>
|
||||||
|
<a href="{% url 'admin_settings' %}" class="text-accent text-decoration-none text-secondary">{% trans "Back to Settings" %}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
42
templates/user/staff_password_create.html
Normal file
42
templates/user/staff_password_create.html
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %} {% block title %}{% trans "Change Password" %} - KAAUH ATS{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="d-flex vh-80 w-100 justify-content-center align-items-center">
|
||||||
|
|
||||||
|
<div class="form-card">
|
||||||
|
|
||||||
|
<h2 id="form-title" class="h3 fw-bold mb-4 text-center">
|
||||||
|
{% trans "Change Password" %}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-muted small mb-4 text-center">
|
||||||
|
{% trans "Please enter your current password and a new password to secure your account." %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="POST" action="{% url 'set_staff_password' user.pk %}" class="space-y-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{{ form|crispy }}
|
||||||
|
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger p-3 small mt-3" role="alert">
|
||||||
|
{% for error in form.non_field_errors %}{{ error }}{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-danger w-100 mt-3">
|
||||||
|
{% trans "Change Password" %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user