easy audit added #16
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_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': {
|
||||
|
||||
@ -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'
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
]
|
||||
|
||||
@ -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.")
|
||||
|
||||
|
||||
@ -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})
|
||||
@ -142,4 +142,5 @@ PyMuPDF
|
||||
pytesseract
|
||||
Pillow
|
||||
python-dotenv
|
||||
django-countries
|
||||
django-countries
|
||||
django-q2
|
||||
@ -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>
|
||||
|
||||
337
templates/includes/easy_logs.html
Normal file
337
templates/includes/easy_logs.html
Normal 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">«</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">»</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -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>
|
||||
|
||||
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