easy audit added #16

Merged
ismail merged 4 commits from frontend into main 2025-10-19 17:25:42 +03:00
41 changed files with 1102 additions and 417 deletions

View File

@ -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_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
@ -298,8 +315,8 @@ customColorPalette = [
},
]
CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional
CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional
# CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional
# CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional
CKEDITOR_5_CONFIGS = {
'default': {
'toolbar': {

View File

@ -16,6 +16,7 @@ urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include(router.urls)),
path('accounts/', include('allauth.urls')),
path('i18n/', include('django.conf.urls.i18n')),
# path('summernote/', include('django_summernote.urls')),
# path('', include('recruitment.urls')),
@ -32,6 +33,7 @@ urlpatterns = [
urlpatterns += i18n_patterns(
path('', include('recruitment.urls')),
)
# 2. URLs that DO have a language prefix (user-facing views)
# This includes the root path (''), which is handled by 'recruitment.urls'

View File

@ -519,4 +519,69 @@ class ProfileImageUploadForm(forms.ModelForm):
# class UserEditForms(forms.ModelForm):
# class Meta:
# 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

View File

@ -12,6 +12,10 @@ from django.utils import timezone
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:
def __init__(self):
self.client_id = settings.LINKEDIN_CLIENT_ID
@ -79,7 +83,8 @@ class LinkedInService:
url = f"https://api.linkedin.com/v2/assets/{quote(asset_urn)}"
headers = {
'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:
@ -96,7 +101,8 @@ class LinkedInService:
headers = {
'Authorization': f'Bearer {self.access_token}',
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0'
'X-Restli-Protocol-Version': LINKEDIN_API_VERSION,
'LinkedIn-Version': LINKEDIN_VERSION,
}
payload = {
@ -138,9 +144,6 @@ class LinkedInService:
try:
status = self.get_asset_status(asset_urn)
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":
logger.info(f"Asset {asset_urn} is READY. Proceeding.")
return True
@ -151,12 +154,10 @@ class LinkedInService:
time.sleep(self.ASSET_STATUS_INTERVAL)
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.")
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.")
return True
@ -253,9 +254,12 @@ class LinkedInService:
message_parts.append("\n" + " ".join(hashtags))
return "\n".join(message_parts)
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."""
def _send_ugc_post(self, person_urn, job_posting, media_category="NONE", media_list=None):
"""
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)
@ -263,25 +267,24 @@ class LinkedInService:
headers = {
'Authorization': f'Bearer {self.access_token}',
'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 = {
"author": f"urn:li:person:{person_urn}",
"lifecycleState": "PUBLISHED",
"specificContent": {
"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"}
}]
}
},
"specificContent": specific_content,
"visibility": {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
}
@ -300,6 +303,28 @@ class LinkedInService:
'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):
"""Main method to create a job announcement post (Image or Text)."""
if not self.access_token:
@ -346,41 +371,12 @@ class LinkedInService:
has_image = False
# === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) ===
message = self._build_post_message(job_posting)
url = "https://api.linkedin.com/v2/ugcPosts"
headers = {
'Authorization': f'Bearer {self.access_token}',
'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
}
# Use the single helper method here
return self._send_ugc_post(
person_urn=person_urn,
job_posting=job_posting,
media_category="NONE"
)
except Exception as e:
logger.error(f"Error creating LinkedIn post: {e}")

View File

@ -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.db.models.deletion
@ -105,10 +105,12 @@ class Migration(migrations.Migration):
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
('join_url', models.URLField(verbose_name='Join URL')),
('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')),
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
('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={
'abstract': False,
@ -185,7 +187,7 @@ class Migration(migrations.Migration):
('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')),
('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={
'verbose_name': 'Form Template',
@ -217,13 +219,14 @@ class Migration(migrations.Migration):
('address', models.TextField(max_length=200, verbose_name='Address')),
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
('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')),
('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')),
('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')),
('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')),
('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')),
@ -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)),
('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_start_date', 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)),
('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)),
('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])),
('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')),
@ -271,7 +275,7 @@ class Migration(migrations.Migration):
('published_at', models.DateTimeField(blank=True, null=True)),
('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)),
('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')),
('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')),
@ -296,10 +300,10 @@ class Migration(migrations.Migration):
('working_days', models.JSONField(verbose_name='Working Days')),
('start_time', models.TimeField(verbose_name='Start 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)')),
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
('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')),
('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')),
@ -322,8 +326,8 @@ class Migration(migrations.Migration):
name='JobPostingImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('post_image', models.ImageField(upload_to='post/')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')),
('post_image', models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size])),
('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')),
],
),
migrations.CreateModel(
@ -405,7 +409,7 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True)),
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
('schedule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
('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')),
],
options={

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = [
]

View File

@ -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 = [
]

View File

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

View File

@ -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 = [
]

View File

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

View File

@ -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 = [
]

View File

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

View File

@ -4,6 +4,11 @@ import logging
import requests
from PyPDF2 import PdfReader
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__)
@ -153,3 +158,39 @@ def handle_reume_parsing_and_scoring(pk):
except Exception as 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

View File

@ -109,5 +109,9 @@ urlpatterns = [
# users urls
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('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')
]

View File

@ -1,7 +1,7 @@
from django.core.exceptions import ValidationError
def validate_image_size(image):
max_size_mb = 2
max_size_mb = 1
if image.size > max_size_mb * 1024 * 1024:
raise ValidationError(f"Image size should not exceed {max_size_mb}MB.")

View File

@ -1,5 +1,6 @@
import json
import requests
from django.utils.translation import gettext as _
from django.contrib.auth.models import User
from rich import print
from django.template.loader import render_to_string
@ -13,7 +14,7 @@ from django.urls import reverse
from django.conf import settings
from django.utils import timezone
from .forms import (
CandidateExamDateForm,
CandidateExamDateForm,
ZoomMeetingForm,
JobPostingForm,
FormTemplateForm,
@ -21,11 +22,13 @@ from .forms import (
BreakTimeFormSet,
JobPostingImageForm,
ProfileImageUploadForm,
StaffUserCreationForm
)
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
from rest_framework import viewsets
from django.contrib import messages
from django.core.paginator import Paginator
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from .linkedin_service import LinkedInService
from .serializers import JobPostingSerializer, CandidateSerializer
from django.shortcuts import get_object_or_404, render, redirect
@ -54,7 +57,8 @@ from .models import (
Candidate,
JobPosting,
ScheduledInterview,
JobPostingImage
JobPostingImage,
Profile
)
import logging
from datastar_py.django import (
@ -319,8 +323,12 @@ def job_detail(request, slug):
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)
@ -407,6 +415,8 @@ def job_detail_candidate(request, slug):
return render(request, "forms/job_detail_candidate.html", {"job": job})
from django_q.tasks import async_task
def post_to_linkedin(request, slug):
"""Post a job to LinkedIn"""
job = get_object_or_404(JobPosting, slug=slug)
@ -415,47 +425,39 @@ def post_to_linkedin(request, slug):
return redirect("job_list")
if request.method == "POST":
try:
# Check if user is authenticated with LinkedIn
if "linkedin_access_token" not in request.session:
linkedin_access_token=request.session.get("linkedin_access_token")
# Check if user is authenticated with LinkedIn
if not "linkedin_access_token":
messages.error(request, "Please authenticate with LinkedIn first.")
return redirect("linkedin_login")
try:
# Clear previous LinkedIn data for re-posting
#Prepare the job object for background processing
job.posted_to_linkedin = False
job.linkedin_post_id = ""
job.linkedin_post_url = ""
job.linkedin_post_status = ""
job.linkedin_post_status = "QUEUED"
job.linkedin_posted_at = None
job.save()
# ENQUEUE THE TASK
# Pass the function path, the job slug, and the token as arguments
# Initialize LinkedIn service
service = LinkedInService()
service.access_token = request.session["linkedin_access_token"]
async_task(
'recruitment.tasks.linkedin_post_task',
job.slug,
linkedin_access_token
)
# Post to LinkedIn
result = service.create_job_post(job)
if result["success"]:
# 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}")
messages.success(
request,
_(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.")
)
except Exception as e:
logger.error(f"Error in post_to_linkedin: {e}")
job.linkedin_post_status = f"ERROR: {str(e)}"
job.save()
messages.error(request, f"Error posting to LinkedIn: {e}")
logger.error(f"Error enqueuing LinkedIn post: {e}")
messages.error(request, _("Failed to start the job posting process. Please try again."))
return redirect("job_detail", slug=job.slug)
@ -2353,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):
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':
profile_form = ProfileImageUploadForm(request.POST, request.FILES, instance=user.profile)
@ -2378,7 +2386,9 @@ def user_profile_image_update(request, pk):
def user_detail(request, pk):
user = get_object_or_404(User, pk=pk)
profile_form = ProfileImageUploadForm(instance=user.profile)
profile_form = ProfileImageUploadForm()
if request.method == 'POST':
first_name=request.POST.get('first_name')
last_name=request.POST.get('last_name')
@ -2394,3 +2404,110 @@ def user_detail(request, pk):
}
return render(request, 'user/profile.html', context)
def easy_logs(request):
"""
Function-based view to display Django Easy Audit logs with tab switching and pagination.
"""
logs_per_page = 20
active_tab = request.GET.get('tab', 'crud')
if active_tab == 'login':
queryset = LoginEvent.objects.order_by('-datetime')
tab_title = _("User Authentication")
elif active_tab == 'request':
queryset = RequestEvent.objects.order_by('-datetime')
tab_title = _("HTTP Requests")
else:
queryset = CRUDEvent.objects.order_by('-datetime')
tab_title = _("Model Changes (CRUD)")
active_tab = 'crud'
paginator = Paginator(queryset, logs_per_page)
page = request.GET.get('page')
try:
logs_page = paginator.page(page)
except PageNotAnInteger:
logs_page = paginator.page(1)
except EmptyPage:
logs_page = paginator.page(paginator.num_pages)
context = {
'logs': logs_page,
'total_count': queryset.count(),
'active_tab': active_tab,
'tab_title': tab_title,
}
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})

View File

@ -142,4 +142,5 @@ PyMuPDF
pytesseract
Pillow
python-dotenv
django-countries
django-countries
django-q2

View File

@ -143,10 +143,16 @@
</div>
</li>
<li><hr class="dropdown-divider my-1"></li>
{% 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="#"><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="#"><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>
{% 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 %}
{% comment %} CORRECTED LINKEDIN BLOCK {% endcomment %}
{% if not request.session.linkedin_authenticated %}
@ -164,6 +170,7 @@
{% endif %}
<li><hr class="dropdown-divider my-1"></li>
{% if request.user.is_authenticated %}
<li>
<form method="post" action="{% url 'account_logout'%}" class="d-inline">
{% csrf_token %}
@ -177,6 +184,7 @@
</button>
</form>
</li>
{% endif %}
</ul>
</li>
</ul>
@ -248,7 +256,7 @@
<main class="container-fluid flex-grow-1" style="max-width: 1600px; margin: 0 auto;">
{% if 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 }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% trans 'Close' %}"></button>
</div>

View File

@ -0,0 +1,337 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "Audit Dashboard" %}{% endblock %}
{% block customCSS %}
<style>
/* ---------------------------------------------------- */
/* 1. Theme Variables (Teal Focus) */
/* ---------------------------------------------------- */
:root {
--color-primary: #007a88; /* Main Teal */
--color-primary-dark: #004d55; /* Dark Teal for Headings, Login Status, and Strong Text */
/* Standard Dark Text (for max visibility) */
/* Muted text */
--color-text-on-dark: #f0f0f0; /* Light text for badges (guaranteed contrast) */
/* Adjusted Status Colors for contrast */
--color-success: #157347;
--color-danger: #bb2d3b;
--color-warning: #ffc107;
--color-info: #0dcaf0;
--color-light: #f8f9fa;
}
/* ---------------------------------------------------- */
/* 2. Layout, Header, and Summary */
/* ---------------------------------------------------- */
.container-fluid {
background-color: var(--color-background-light);
}
.audit-card {
background-color: #ffffff;
border-radius: 0.75rem;
border: 1px solid #e9ecef;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
min-height: 60vh;
}
.dashboard-header {
color: var(--color-primary-dark);
border-bottom: 1px solid #e9ecef;
padding-bottom: 1rem;
}
.summary-alert {
border-color: var(--color-primary) !important;
background-color: var(--color-primary-light) !important;
}
.summary-alert h6, .summary-alert strong {
color: var(--color-primary-dark) !important;
}
/* ---------------------------------------------------- */
/* 3. Tabs Styling */
/* ---------------------------------------------------- */
.nav-tabs {
border-bottom: 2px solid #e9ecef;
background-color: #ffffff;
padding-top: 1rem;
border-radius: 0.75rem 0.75rem 0 0;
}
.nav-link-es {
color: var(--color-text-secondary);
border: none;
}
.nav-link-es.active {
color: var(--color-primary) !important;
font-weight: 600;
border-bottom: 3px solid var(--color-primary) !important;
}
.nav-link:hover:not(.active) {
color: var(--color-primary);
}
/* ---------------------------------------------------- */
/* 4. Table and Text Contrast */
/* ---------------------------------------------------- */
.table th {
color: var(--color-text-secondary);
}
.table td {
color: var(--color-text-dark);
}
pre {
background-color: var(--color-light) !important;
color: var(--color-text-dark) !important;
}
code {
color: var(--color-text-dark) !important;
}
/* ---------------------------------------------------- */
/* 5. BADGE VISIBILITY FIXES (Guaranteed Colors) */
/* ---------------------------------------------------- */
.badge {
font-weight: 600;
line-height: 1.4;
padding: 0.4em 0.6em;
min-width: 65px;
text-align: center;
text-transform: uppercase;
/* Ensure z-index doesn't cause issues if elements overlap */
position: relative;
}
/* Dark Badges (CRUD Create/Delete & Login/Logout Type) - Use light text */
.badge-crud-create {
background-color: var(--color-success) !important;
color: var(--color-text-on-dark) !important;
}
.badge-crud-delete {
background-color: var(--color-danger) !important;
color: var(--color-text-on-dark) !important;
}
.badge-login-status {
background-color: var(--color-primary-dark) !important;
color: var(--color-text-on-dark) !important;
}
/* Light Badges (CRUD Update & Request Method) - Use dark text */
.badge-crud-update {
background-color: var(--color-warning) !important;
color: var(--color-text-dark) !important;
}
.badge-request-method {
background-color: var(--color-info) !important;
color: var(--color-text-dark) !important;
}
/* Pagination - Fully Teal Themed */
.pagination .page-item.active .page-link {
background-color: var(--color-primary) !important;
border-color: var(--color-primary) !important;
color: var(--color-text-on-dark) !important;
}
.pagination .page-link {
color: var(--color-primary) !important; /* FIX: Added !important here */
border: 1px solid #dee2e6;
}
.pagination .page-item.disabled .page-link {
color: var(--color-text-secondary) !important;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid pt-5 pb-5 px-lg-5" style="background-color: var(--color-background-light);">
<h1 class="h3 fw-bold dashboard-header mb-4 px-3">
<i class="fas fa-shield-alt me-2" style="color: var(--color-primary);"></i>{% trans "System Audit Logs" %}
</h1>
<div class="alert summary-alert border-start border-5 p-3 mb-5 mx-3" role="alert">
<h6 class="mb-1">{% trans "Viewing Logs" %}: <strong>{{ tab_title }}</strong></h6>
<p class="mb-0 small">
{% trans "Displaying" %}: **{{ logs.start_index }}-{{ logs.end_index }}** {% trans "of" %}
**{{ total_count }}** {% trans "total records." %}
</p>
</div>
<div class="audit-card mx-3">
<ul class="nav nav-tabs px-3" id="auditTabs" role="tablist">
<li class="nav-item" role="presentation">
<a class="nav-link nav-link-es {% if active_tab == 'crud' %}active{% endif %}"
id="crud-tab" href="?tab=crud" aria-controls="crud">
<i class="fas fa-database me-2"></i>{% trans "Model Changes (CRUD)" %}
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link nav-link-es {% if active_tab == 'login' %}active{% endif %}"
id="login-tab" href="?tab=login" aria-controls="login">
<i class="fas fa-user-lock me-2"></i>{% trans "User Authentication" %}
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link nav-link-es {% if active_tab == 'request' %}active{% endif %}"
id="request-tab" href="?tab=request" aria-controls="request">
<i class="fas fa-globe me-2"></i>{% trans "HTTP Requests" %}
</a>
</li>
</ul>
<div class="tab-content p-4" id="auditTabsContent">
<div class="tab-pane fade show active" role="tabpanel">
<div class="table-responsive">
<table class="table table-striped table-hover small">
<thead>
{% if active_tab == 'crud' %}
<tr>
<th scope="col" style="width: 15%;">{% trans "Date/Time" %}</th>
<th scope="col" style="width: 15%;">{% trans "User" %}</th>
<th scope="col" style="width: 10%;">{% trans "Action" %}</th>
<th scope="col" style="width: 20%;">{% trans "Model" %}</th>
<th scope="col" style="width: 10%;">{% trans "Object PK" %}</th>
<th scope="col" style="width: 30%;">{% trans "Changes" %}</th>
</tr>
{% elif active_tab == 'login' %}
<tr>
<th scope="col" style="width: 20%;">{% trans "Date/Time" %}</th>
<th scope="col" style="width: 25%;">{% trans "User" %}</th>
<th scope="col" style="width: 15%;">{% trans "Type" %}</th>
<th scope="col" style="width: 10%;">{% trans "Status" %}</th>
<th scope="col" style="width: 30%;">{% trans "IP Address" %}</th>
</tr>
{% elif active_tab == 'request' %}
<tr>
<th scope="col" style="width: 15%;">{% trans "Date/Time" %}</th>
<th scope="col" style="width: 15%;">{% trans "User" %}</th>
<th scope="col" style="width: 10%;">{% trans "Method" %}</th>
<th scope="col" style="width: 45%;">{% trans "Path" %}</th>
</tr>
{% endif %}
</thead>
<tbody>
{% for log in logs.object_list %}
{% if active_tab == 'crud' %}
<tr>
<td>{{ log.datetime|date:"Y-m-d H:i:s" }}</td>
<td>{{ log.user.get_full_name|default:log.user.username|default:"N/A" }}</td>
<td>
<span class="badge rounded-pill
{% if log.event_type == 1 %}badge-crud-create
{% elif log.event_type == 2 %}badge-crud-update
{% else %}badge-crud-delete{% endif %}">
{% if log.event_type == 1 %}<i class="fas fa-plus-circle me-1"></i>{% trans "CREATE" %}
{% elif log.event_type == 2 %}<i class="fas fa-edit me-1"></i>{% trans "UPDATE" %}
{% else %}<i class="fas fa-trash-alt me-1"></i>{% trans "DELETE" %}{% endif %}
</span>
</td>
<td><code style="color: var(--color-text-dark) !important;">{{ log.content_type.app_label }}.{{ log.content_type.model }}</code></td>
<td>{{ log.object_id }}</td>
<td>
<pre class="p-2 m-0" style="font-size: 0.65rem; max-height: 80px; overflow-y: auto;">{{ log.changed_fields }}</pre>
</td>
</tr>
{% elif active_tab == 'login' %}
<tr>
<td>{{ log.datetime|date:"Y-m-d H:i:s" }}</td>
<td>
{% with user_obj=log.user %}
{% if user_obj %}
{{ user_obj.get_full_name|default:user_obj.username }}
{% else %}
<span class="text-danger fw-bold">{{ log.username|default:"N/A" }}</span>
{% endif %}
{% endwith %}
</td>
<td>
<span class="badge rounded-pill badge-login-status">
{% if log.login_type == 0 %}{% trans "Login" %}
{% elif log.login_type == 1 %}{% trans "Logout" %}
{% else %}{% trans "Failed Login" %}{% endif %}
</span>
</td>
<td>
{% if log.login_type == 2 %}
<i class="fas fa-times-circle me-1" style="color: var(--color-danger);"></i>{% trans "Failed" %}
{% else %}
<i class="fas fa-check-circle me-1" style="color: var(--color-success);"></i>{% trans "Success" %}
{% endif %}
</td>
<td>{{ log.remote_ip|default:"Unknown" }}</td>
</tr>
{% elif active_tab == 'request' %}
<tr>
<td>{{ log.datetime|date:"Y-m-d H:i:s" }}</td>
<td>{{ log.user.get_full_name|default:log.user.username|default:"Anonymous" }}</td>
<td>
<span class="badge rounded-pill badge-request-method">{{ log.method }}</span>
</td>
<td><code class="text-break small" style="color: var(--color-text-dark) !important;">{{ log.url}}</code></td>
</tr>
{% endif %}
{% empty %}
<tr><td colspan="6" class="text-center text-muted py-5">
<i class="fas fa-info-circle me-2"></i>{% trans "No logs found for this section or the database is empty." %}
</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if logs.has_other_pages %}
<nav aria-label="Audit Log Pagination" class="pt-3">
<ul class="pagination justify-content-end">
<li class="page-item {% if not logs.has_previous %}disabled{% endif %}">
<a class="page-link"
href="?tab={{ active_tab }}{% if logs.has_previous %}&page={{ logs.previous_page_number }}{% endif %}"
aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% for i in logs.paginator.page_range %}
{% comment %} Limiting pages displayed around the current page {% endcomment %}
{% if i > logs.number|add:'-3' and i < logs.number|add:'3' %}
<li class="page-item {% if logs.number == i %}active{% endif %}">
<a class="page-link"
href="?tab={{ active_tab }}&page={{ i }}">
{{ i }}
</a>
</li>
{% elif i == logs.number|add:'-3' or i == logs.number|add:'3' %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endfor %}
<li class="page-item {% if not logs.has_next %}disabled{% endif %}">
<a class="page-link"
href="?tab={{ active_tab }}{% if logs.has_next %}&page={{ logs.next_page_number }}{% endif %}"
aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -80,6 +80,12 @@
color: var(--kaauh-teal);
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-badge {
@ -207,7 +213,7 @@
<div class="card meeting-card h-100 shadow-sm">
<div class="card-body d-flex flex-column">
<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 }}">
{{ meeting.status|title }}
</span>
@ -265,7 +271,7 @@
<tbody>
{% for meeting in meetings %}
<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.start_time|date:"M d, Y H:i" }}</td>
<td>{{ meeting.duration }} min</td>

View 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">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">&laquo;</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">&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">&raquo;</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 %}

View 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 %}

View 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 %}