diff --git a/NorahUniversity/__pycache__/settings.cpython-312.pyc b/NorahUniversity/__pycache__/settings.cpython-312.pyc index 7901847..8d89095 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-312.pyc and b/NorahUniversity/__pycache__/settings.cpython-312.pyc differ diff --git a/NorahUniversity/__pycache__/urls.cpython-312.pyc b/NorahUniversity/__pycache__/urls.cpython-312.pyc index d8cddd7..e44eacc 100644 Binary files a/NorahUniversity/__pycache__/urls.cpython-312.pyc and b/NorahUniversity/__pycache__/urls.cpython-312.pyc differ diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 6bb6388..c51ea0c 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -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': { diff --git a/NorahUniversity/urls.py b/NorahUniversity/urls.py index d96e6c8..90cdd3a 100644 --- a/NorahUniversity/urls.py +++ b/NorahUniversity/urls.py @@ -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' diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc index cea0bd5..bba0bac 100644 Binary files a/recruitment/__pycache__/forms.cpython-312.pyc and b/recruitment/__pycache__/forms.cpython-312.pyc differ diff --git a/recruitment/__pycache__/linkedin_service.cpython-312.pyc b/recruitment/__pycache__/linkedin_service.cpython-312.pyc index 2eddf44..63027e7 100644 Binary files a/recruitment/__pycache__/linkedin_service.cpython-312.pyc and b/recruitment/__pycache__/linkedin_service.cpython-312.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-312.pyc b/recruitment/__pycache__/signals.cpython-312.pyc index ece9632..e9aa013 100644 Binary files a/recruitment/__pycache__/signals.cpython-312.pyc and b/recruitment/__pycache__/signals.cpython-312.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc index 0c55bfb..b4efb3c 100644 Binary files a/recruitment/__pycache__/urls.cpython-312.pyc and b/recruitment/__pycache__/urls.cpython-312.pyc differ diff --git a/recruitment/__pycache__/validators.cpython-312.pyc b/recruitment/__pycache__/validators.cpython-312.pyc index 70d42fc..56c5562 100644 Binary files a/recruitment/__pycache__/validators.cpython-312.pyc and b/recruitment/__pycache__/validators.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc index 41776d0..cf28cc0 100644 Binary files a/recruitment/__pycache__/views.cpython-312.pyc and b/recruitment/__pycache__/views.cpython-312.pyc differ diff --git a/recruitment/forms.py b/recruitment/forms.py index 30f1ca9..bd79f8b 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -519,4 +519,69 @@ class ProfileImageUploadForm(forms.ModelForm): # class UserEditForms(forms.ModelForm): # class Meta: # model = User -# fields = ['first_name', 'last_name'] \ No newline at end of file +# 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 + + + diff --git a/recruitment/linkedin_service.py b/recruitment/linkedin_service.py index 8593fa7..d4095ae 100644 --- a/recruitment/linkedin_service.py +++ b/recruitment/linkedin_service.py @@ -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}") diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py index 3a3b33a..1895908 100644 --- a/recruitment/migrations/0001_initial.py +++ b/recruitment/migrations/0001_initial.py @@ -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={ diff --git a/recruitment/migrations/0002_alter_jobposting_status.py b/recruitment/migrations/0002_alter_jobposting_status.py deleted file mode 100644 index d2fa6de..0000000 --- a/recruitment/migrations/0002_alter_jobposting_status.py +++ /dev/null @@ -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), - ), - ] diff --git a/recruitment/migrations/0002_candidate_is_potential_candidate_and_more.py b/recruitment/migrations/0002_candidate_is_potential_candidate_and_more.py deleted file mode 100644 index 66870d2..0000000 --- a/recruitment/migrations/0002_candidate_is_potential_candidate_and_more.py +++ /dev/null @@ -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), - ), - ] diff --git a/recruitment/migrations/0003_alter_candidate_exam_date.py b/recruitment/migrations/0003_alter_candidate_exam_date.py deleted file mode 100644 index 8cada1f..0000000 --- a/recruitment/migrations/0003_alter_candidate_exam_date.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0003_rename_start_date_jobposting_joining_date_and_more.py b/recruitment/migrations/0003_rename_start_date_jobposting_joining_date_and_more.py deleted file mode 100644 index 36a4a08..0000000 --- a/recruitment/migrations/0003_rename_start_date_jobposting_joining_date_and_more.py +++ /dev/null @@ -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), - ), - ] diff --git a/recruitment/migrations/0004_alter_candidate_interview_date.py b/recruitment/migrations/0004_alter_candidate_interview_date.py deleted file mode 100644 index ec8feb1..0000000 --- a/recruitment/migrations/0004_alter_candidate_interview_date.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0005_remove_interviewschedule_breaks_and_more.py b/recruitment/migrations/0005_remove_interviewschedule_breaks_and_more.py deleted file mode 100644 index 5784ef2..0000000 --- a/recruitment/migrations/0005_remove_interviewschedule_breaks_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0006_zoommeeting_meeting_status.py b/recruitment/migrations/0006_zoommeeting_meeting_status.py deleted file mode 100644 index e80ac90..0000000 --- a/recruitment/migrations/0006_zoommeeting_meeting_status.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0007_remove_zoommeeting_meeting_status_zoommeeting_status.py b/recruitment/migrations/0007_remove_zoommeeting_meeting_status_zoommeeting_status.py deleted file mode 100644 index e171332..0000000 --- a/recruitment/migrations/0007_remove_zoommeeting_meeting_status_zoommeeting_status.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0008_zoommeeting_password.py b/recruitment/migrations/0008_zoommeeting_password.py deleted file mode 100644 index 5420d0d..0000000 --- a/recruitment/migrations/0008_zoommeeting_password.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0009_merge_20251013_1714.py b/recruitment/migrations/0009_merge_20251013_1714.py deleted file mode 100644 index 25a93ae..0000000 --- a/recruitment/migrations/0009_merge_20251013_1714.py +++ /dev/null @@ -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 = [ - ] diff --git a/recruitment/migrations/0009_merge_20251013_1718.py b/recruitment/migrations/0009_merge_20251013_1718.py deleted file mode 100644 index 43811e8..0000000 --- a/recruitment/migrations/0009_merge_20251013_1718.py +++ /dev/null @@ -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 = [ - ] diff --git a/recruitment/migrations/0010_alter_scheduledinterview_schedule.py b/recruitment/migrations/0010_alter_scheduledinterview_schedule.py deleted file mode 100644 index 0a24d5e..0000000 --- a/recruitment/migrations/0010_alter_scheduledinterview_schedule.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0010_merge_20251013_1819.py b/recruitment/migrations/0010_merge_20251013_1819.py deleted file mode 100644 index 6acb9b0..0000000 --- a/recruitment/migrations/0010_merge_20251013_1819.py +++ /dev/null @@ -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 = [ - ] diff --git a/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py b/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py deleted file mode 100644 index a961dd5..0000000 --- a/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py +++ /dev/null @@ -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]), - ), - ] diff --git a/recruitment/migrations/0012_merge_20251014_1403.py b/recruitment/migrations/0012_merge_20251014_1403.py deleted file mode 100644 index 2827f2a..0000000 --- a/recruitment/migrations/0012_merge_20251014_1403.py +++ /dev/null @@ -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 = [ - ] diff --git a/recruitment/migrations/0013_alter_formtemplate_created_by.py b/recruitment/migrations/0013_alter_formtemplate_created_by.py deleted file mode 100644 index cbdb0fb..0000000 --- a/recruitment/migrations/0013_alter_formtemplate_created_by.py +++ /dev/null @@ -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), - ), - ] diff --git a/recruitment/tasks.py b/recruitment/tasks.py index abfa760..b6c7b49 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -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 + + + + diff --git a/recruitment/urls.py b/recruitment/urls.py index 4bc00ac..4838aee 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -109,5 +109,9 @@ urlpatterns = [ # users urls path('user/',views.user_detail,name='user_detail'), path('user/user_profile_image_update/',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//',views.set_staff_password,name='set_staff_password') ] diff --git a/recruitment/validators.py b/recruitment/validators.py index 8648da6..6277b64 100644 --- a/recruitment/validators.py +++ b/recruitment/validators.py @@ -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.") diff --git a/recruitment/views.py b/recruitment/views.py index 68d11b1..48c8715 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -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}) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 98ed8b4..5fee9fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -142,4 +142,5 @@ PyMuPDF pytesseract Pillow python-dotenv -django-countries \ No newline at end of file +django-countries +django-q2 \ No newline at end of file diff --git a/templates/account/signup.html b/templates/account/signup.html deleted file mode 100644 index e69de29..0000000 diff --git a/templates/base.html b/templates/base.html index 3738bdf..cdccb91 100644 --- a/templates/base.html +++ b/templates/base.html @@ -143,10 +143,16 @@
  • + {% if request.user.is_authenticated %}
  • {% trans "My Profile" %}
  • -
  • {% trans "Settings" %}
  • -
  • {% trans "Activity Log" %}
  • -
  • {% trans "Help & Support" %}
  • + + + {% if request.user.is_superuser %} +
  • {% trans "Settings" %}
  • +
  • {% trans "Activity Log" %}
  • +
  • {% trans "Help & Support" %}
  • + {% endif %} + {% endif %} {% comment %} CORRECTED LINKEDIN BLOCK {% endcomment %} {% if not request.session.linkedin_authenticated %} @@ -164,6 +170,7 @@ {% endif %}
  • + {% if request.user.is_authenticated %}
  • {% csrf_token %} @@ -177,6 +184,7 @@
  • + {% endif %} @@ -248,7 +256,7 @@
    {% if messages %} {% for message in messages %} -