diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc index e8051b1..4c34029 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 56e8775..2eddf44 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__/models.cpython-312.pyc b/recruitment/__pycache__/models.cpython-312.pyc index a0ac22e..fa7751d 100644 Binary files a/recruitment/__pycache__/models.cpython-312.pyc and b/recruitment/__pycache__/models.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc index 38268c6..aff3e19 100644 Binary files a/recruitment/__pycache__/views.cpython-312.pyc and b/recruitment/__pycache__/views.cpython-312.pyc differ diff --git a/recruitment/linkedin_service.py b/recruitment/linkedin_service.py index 1e97f53..8593fa7 100644 --- a/recruitment/linkedin_service.py +++ b/recruitment/linkedin_service.py @@ -1,10 +1,14 @@ # jobs/linkedin_service.py import uuid -from urllib.parse import quote +import re +from html import unescape +from urllib.parse import quote, urlencode import requests import logging from django.conf import settings -from urllib.parse import urlencode, quote +import time +import random +from django.utils import timezone logger = logging.getLogger(__name__) @@ -14,7 +18,12 @@ class LinkedInService: self.client_secret = settings.LINKEDIN_CLIENT_SECRET self.redirect_uri = settings.LINKEDIN_REDIRECT_URI self.access_token = None + # Configuration for image processing wait time + self.ASSET_STATUS_TIMEOUT = 15 # Max time (seconds) to wait for image processing + self.ASSET_STATUS_INTERVAL = 2 # Check every 2 seconds + # --- AUTHENTICATION & PROFILE --- + def get_auth_url(self): """Generate LinkedIn OAuth URL""" params = { @@ -28,7 +37,6 @@ class LinkedInService: def get_access_token(self, code): """Exchange authorization code for access token""" - # This function exchanges LinkedIn’s temporary authorization code for a usable access token. url = "https://www.linkedin.com/oauth/v2/accessToken" data = { 'grant_type': 'authorization_code', @@ -42,12 +50,6 @@ class LinkedInService: response = requests.post(url, data=data, timeout=60) response.raise_for_status() token_data = response.json() - """ - Example response:{ - "access_token": "AQXq8HJkLmNpQrStUvWxYz...", - "expires_in": 5184000 - } - """ self.access_token = token_data.get('access_token') return self.access_token except Exception as e: @@ -55,7 +57,7 @@ class LinkedInService: raise def get_user_profile(self): - """Get user profile information""" + """Get user profile information (used to get person URN)""" if not self.access_token: raise Exception("No access token available") @@ -64,16 +66,32 @@ class LinkedInService: try: response = requests.get(url, headers=headers, timeout=60) - response.raise_for_status() # Ensure we raise an error for bad responses(4xx, 5xx) and does nothing for 2xx(success) - return response.json() # returns a dict from json response (deserialize) + response.raise_for_status() + return response.json() except Exception as e: logger.error(f"Error getting user profile: {e}") raise + # --- ASSET UPLOAD & STATUS --- + def get_asset_status(self, asset_urn): + """Checks the status of a registered asset (image) to ensure it's READY.""" + 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' + } + + try: + response = requests.get(url, headers=headers, timeout=10) + response.raise_for_status() + return response.json().get('status') + except Exception as e: + logger.error(f"Error checking asset status for {asset_urn}: {e}") + return "FAILED" def register_image_upload(self, person_urn): - """Step 1: Register image upload with LinkedIn""" + """Step 1: Register image upload with LinkedIn, getting the upload URL and asset URN.""" url = "https://api.linkedin.com/v2/assets?action=registerUpload" headers = { 'Authorization': f'Bearer {self.access_token}', @@ -101,9 +119,8 @@ class LinkedInService: 'asset': data['value']['asset'] } - def upload_image_to_linkedin(self, upload_url, image_file): - """Step 2: Upload actual image file to LinkedIn""" - # Open and read the Django ImageField + def upload_image_to_linkedin(self, upload_url, image_file, asset_urn): + """Step 2: Upload image file and poll for 'READY' status.""" image_file.open() image_content = image_file.read() image_file.close() @@ -114,90 +131,223 @@ class LinkedInService: response = requests.post(upload_url, headers=headers, data=image_content, timeout=60) response.raise_for_status() + + # --- CRITICAL FIX: POLL FOR ASSET STATUS --- + start_time = time.time() + while time.time() - start_time < self.ASSET_STATUS_TIMEOUT: + 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 + if status == "FAILED": + raise Exception(f"LinkedIn image processing failed for asset {asset_urn}") + + logger.info(f"Asset {asset_urn} status: {status}. Waiting...") + 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) + logger.warning(f"Asset {asset_urn} timed out, but upload succeeded. Forcing post attempt.") return True + + # --- POSTING LOGIC --- + + def clean_html_for_social_post(self, html_content): + """Converts safe HTML to plain text with basic formatting (bullets, bold, newlines).""" + if not html_content: + return "" + + text = html_content + + # 1. Convert Bolding tags to *Markdown* + text = re.sub(r'(.*?)', r'*\1*', text, flags=re.IGNORECASE) + text = re.sub(r'(.*?)', r'*\1*', text, flags=re.IGNORECASE) + + # 2. Handle Lists: Convert
  • tags into a bullet point + text = re.sub(r'', '\n', text, flags=re.IGNORECASE) + text = re.sub(r']*>', 'β€’ ', text, flags=re.IGNORECASE) + text = re.sub(r'
  • ', '\n', text, flags=re.IGNORECASE) + + # 3. Handle Paragraphs and Line Breaks + text = re.sub(r'

    ', '\n\n', text, flags=re.IGNORECASE) + text = re.sub(r'
    ', '\n', text, flags=re.IGNORECASE) + + # 4. Strip all remaining, unsupported HTML tags + clean_text = re.sub(r'<[^>]+>', '', text) + + # 5. Unescape HTML entities + clean_text = unescape(clean_text) + + # 6. Clean up excessive whitespace/newlines + clean_text = re.sub(r'(\n\s*){3,}', '\n\n', clean_text).strip() + + return clean_text + + def hashtags_list(self, hash_tags_str): + """Convert comma-separated hashtags string to list""" + if not hash_tags_str: + return ["#HigherEd", "#Hiring", "#UniversityJobs"] + + tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()] + tags = [tag if tag.startswith('#') else f'#{tag}' for tag in tags] + + if not tags: + return ["#HigherEd", "#Hiring", "#UniversityJobs"] + + return tags + + def _build_post_message(self, job_posting): + """Centralized logic to construct the professionally formatted text message.""" + message_parts = [ + f"πŸ”₯ *Job Alert!* We’re looking for a talented professional to join our team.", + f"πŸ‘‰ **{job_posting.title}** πŸ‘ˆ", + ] + + if job_posting.department: + message_parts.append(f"*{job_posting.department}*") + + message_parts.append("\n" + "=" * 25 + "\n") + + # KEY DETAILS SECTION + details_list = [] + if job_posting.job_type: + details_list.append(f"πŸ’Ό Type: {job_posting.get_job_type_display()}") + if job_posting.get_location_display() != 'Not specified': + details_list.append(f"πŸ“ Location: {job_posting.get_location_display()}") + if job_posting.workplace_type: + details_list.append(f"🏠 Workplace: {job_posting.get_workplace_type_display()}") + if job_posting.salary_range: + details_list.append(f"πŸ’° Salary: {job_posting.salary_range}") + + if details_list: + message_parts.append("*Key Information*:") + message_parts.extend(details_list) + message_parts.append("\n") + + # DESCRIPTION SECTION + clean_description = self.clean_html_for_social_post(job_posting.description) + if clean_description: + message_parts.append(f"πŸ”Ž *About the Role:*\n{clean_description}") + + # CALL TO ACTION + if job_posting.application_url: + message_parts.append(f"\n\n---") + message_parts.append(f"πŸ”— **APPLY NOW:** {job_posting.application_url}") + + # HASHTAGS + hashtags = self.hashtags_list(job_posting.hash_tags) + if job_posting.department: + dept_hashtag = f"#{job_posting.department.replace(' ', '')}" + hashtags.insert(0, dept_hashtag) + + message_parts.append("\n" + " ".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.""" + + 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": "IMAGE", + "media": [{ + "status": "READY", + "media": asset_urn, + "description": {"text": job_posting.title}, + "originalUrl": job_posting.application_url, + "title": {"text": "Apply Now"} + }] + } + }, + "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 + } + def create_job_post(self, job_posting): - """Create a job announcement post on LinkedIn (with image support)""" + """Main method to create a job announcement post (Image or Text).""" if not self.access_token: raise Exception("Not authenticated with LinkedIn") try: - # Get user profile for person URN profile = self.get_user_profile() person_urn = profile.get('sub') - if not person_urn: raise Exception("Could not retrieve LinkedIn user ID") - # Check if job has an image + asset_urn = None + has_image = False + + # Check for image and attempt post try: - image_upload = job_posting.files.first() - has_image = image_upload and image_upload.linkedinpost_image + # Assuming correct model path: job_posting.related_model_name.first().image_field_name + image_upload = job_posting.post_images.first().post_image + has_image = image_upload is not None except Exception: - has_image = False + pass # No image available if has_image: - # === POST WITH IMAGE === try: - # Step 1: Register image upload + # Step 1: Register upload_info = self.register_image_upload(person_urn) + asset_urn = upload_info['asset'] - # Step 2: Upload image + # Step 2: Upload and WAIT FOR READY (Crucial for 422 fix) self.upload_image_to_linkedin( upload_info['upload_url'], - image_upload.linkedinpost_image + image_upload, + asset_urn ) # Step 3: Create post with image return self.create_job_post_with_image( - job_posting, - image_upload.linkedinpost_image, - person_urn, - upload_info['asset'] + job_posting, image_upload, person_urn, asset_urn ) except Exception as e: - logger.error(f"Image upload failed: {e}") - # Fall back to text-only post if image upload fails - has_image = False + logger.error(f"Image post failed, falling back to text: {e}") + # Force fallback to text-only if image posting fails + has_image = False - # === FALLBACK TO URL/ARTICLE POST === - # Add unique timestamp to prevent duplicates - from django.utils import timezone - import random - unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})" + # === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) === + message = self._build_post_message(job_posting) - message_parts = [f"πŸš€ **We're Hiring: {job_posting.title}**"] - if job_posting.department: - message_parts.append(f"**Department:** {job_posting.department}") - if job_posting.description: - message_parts.append(f"\n{job_posting.description}") - - details = [] - if job_posting.job_type: - details.append(f"πŸ’Ό {job_posting.get_job_type_display()}") - if job_posting.get_location_display() != 'Not specified': - details.append(f"πŸ“ {job_posting.get_location_display()}") - if job_posting.workplace_type: - details.append(f"🏠 {job_posting.get_workplace_type_display()}") - if job_posting.salary_range: - details.append(f"πŸ’° {job_posting.salary_range}") - - if details: - message_parts.append("\n" + " | ".join(details)) - - if job_posting.application_url: - message_parts.append(f"\nπŸ”— **Apply now:** {job_posting.application_url}") - - hashtags = self.hashtags_list(job_posting.hash_tags) - if job_posting.department: - dept_hashtag = f"#{job_posting.department.replace(' ', '')}" - hashtags.insert(0, dept_hashtag) - - message_parts.append("\n\n" + " ".join(hashtags)) - message_parts.append(unique_suffix) - message = "\n".join(message_parts) - - # πŸ”₯ FIX URL - REMOVE TRAILING SPACES πŸ”₯ url = "https://api.linkedin.com/v2/ugcPosts" headers = { 'Authorization': f'Bearer {self.access_token}', @@ -211,20 +361,14 @@ class LinkedInService: "specificContent": { "com.linkedin.ugc.ShareContent": { "shareCommentary": {"text": message}, - "shareMediaCategory": "ARTICLE", - "media": [{ - "status": "READY", - "description": {"text": f"Apply for {job_posting.title} at our university!"}, - "originalUrl": job_posting.application_url, - "title": {"text": job_posting.title} - }] + "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() @@ -244,18 +388,4 @@ class LinkedInService: 'success': False, 'error': str(e), 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500 - } - - - - def hashtags_list(self,hash_tags_str): - """Convert comma-separated hashtags string to list""" - if not hash_tags_str: - return [""] - tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()] - if not tags: - return ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"] - - return tags - - + } \ No newline at end of file diff --git a/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py b/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py new file mode 100644 index 0000000..a961dd5 --- /dev/null +++ b/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py @@ -0,0 +1,25 @@ +# 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/models.py b/recruitment/models.py index d53801e..b662c0e 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -1,6 +1,6 @@ from django.db import models from django.utils import timezone -from .validators import validate_hash_tags +from .validators import validate_hash_tags, validate_image_size from django.contrib.auth.models import User from django.core.validators import URLValidator from django.utils.translation import gettext_lazy as _ @@ -249,8 +249,8 @@ class JobPosting(Base): class JobPostingImage(models.Model): - job=models.ForeignKey('JobPosting',on_delete=models.CASCADE,related_name='post_images') - post_image = models.ImageField(upload_to='post/') + job=models.OneToOneField('JobPosting',on_delete=models.CASCADE,related_name='post_images') + post_image = models.ImageField(upload_to='post/',validators=[validate_image_size]) class Candidate(Base): diff --git a/recruitment/views.py b/recruitment/views.py index 3b5efa9..a297a59 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -1501,9 +1501,14 @@ def candidate_screening_view(request, slug): Manage candidate tiers and stage transitions """ job = get_object_or_404(JobPosting, slug=slug) - + applied_count=job.candidates.filter(stage='Applied').count() + exam_count=job.candidates.filter(stage='Exam').count() + interview_count=job.candidates.filter(stage='interview').count() + offer_count=job.candidates.filter(stage='Offer').count() # Get all candidates for this job, ordered by match score (descending) candidates = job.candidates.filter(stage="Applied").order_by("-match_score") + + # Get tier categorization parameters # tier1_count = int(request.GET.get("tier1_count", 100)) @@ -1601,19 +1606,55 @@ def candidate_screening_view(request, slug): # messages.info(request, "All Tier 1 candidates are already marked as Candidates") # Group candidates by current stage for display - stage_groups = { - "Applied": candidates.filter(stage="Applied"), - "Exam": candidates.filter(stage="Exam"), - "Interview": candidates.filter(stage="Interview"), - "Offer": candidates.filter(stage="Offer"), - } + # stage_groups = { + # "Applied": candidates.filter(stage="Applied"), + # "Exam": candidates.filter(stage="Exam"), + # "Interview": candidates.filter(stage="Interview"), + # "Offer": candidates.filter(stage="Offer"), + # } + min_ai_score_str = request.GET.get('min_ai_score') + tier1_count_str = request.GET.get('tier1_count') + + try: + # Check if the string value exists and is not an empty string before conversion + if min_ai_score_str: + min_ai_score = int(min_ai_score_str) + else: + min_ai_score = 0 + + if tier1_count_str: + tier1_count = int(tier1_count_str) + else: + tier1_count = 0 + + except ValueError: + # This catches if the user enters non-numeric text (e.g., "abc") + min_ai_score = 0 + tier1_count = 0 + print(min_ai_score) + print(tier1_count) + # You can now safely use min_ai_score and tier1_count as integers (0 or greater) + if min_ai_score > 0: + candidates = candidates.filter(match_score__gte=min_ai_score) + print(candidates) + + if tier1_count > 0: + candidates = candidates[:tier1_count] + context = { "job": job, "candidates": candidates, # "stage_groups": stage_groups, # "tier1_count": tier1_count, # "total_candidates": candidates.count(), + 'min_ai_score':min_ai_score, + 'tier1_count':tier1_count, + 'applied_count':applied_count, + 'exam_count':exam_count, + 'interview_count':interview_count, + 'offer_count':offer_count + } return render(request, "recruitment/candidate_screening_view.html", context) diff --git a/templates/jobs/job_detail.html b/templates/jobs/job_detail.html index 78d6579..2103fcc 100644 --- a/templates/jobs/job_detail.html +++ b/templates/jobs/job_detail.html @@ -269,11 +269,14 @@
    -
    +
    {# RIGHT TABS NAVIGATION #}
    - {% comment %} --- START OF TABLE VIEW (Data relied upon context variable 'jobs') --- {% endcomment %} + {# --- START OF JOB LIST CONTAINER --- #}
    - {% comment %} Placeholder for View Switcher {% endcomment %} - {% include "includes/_list_view_switcher.html" with list_id="job-list" %} + {# View Switcher (Contains the Card/Table buttons and JS/CSS logic) #} + {% include "includes/_list_view_switcher.html" with list_id="job-list" %} + {# 1. TABLE VIEW (Default Active) #}
    + + {# --- Corrected Multi-Row Header Structure --- #} - - {% comment %} - {% endcomment %} - - - + + + + - + + + + + + + + + + + - {% comment %} This loop relies on the 'jobs' variable passed from the Django view {% endcomment %} {% for job in jobs %} - - {% comment %} - {% endcomment %} + - - {# CANDIDATE MANAGEMENT DATA - 7 SEPARATE COLUMNS CORRESPONDING TO THE HEADER #} + {# CANDIDATE MANAGEMENT DATA - URLS NEUTRALIZED #} @@ -314,7 +337,53 @@ + + {# 2. CARD VIEW (Previously Missing) - Added Bootstrap row/col structure for layout #} +
    + {% for job in jobs %} +
    +
    +
    +
    +
    {{ job.title }}
    + {{ job.status }} +
    +

    ID: {{ job.pk }} | Source: {{ job.get_source }}

    + +
      +
    • {% trans "Applicants" %}:{{ job.metrics.applied|default:"0" }}
    • +
    • {% trans "Offers Made" %}: {{ job.metrics.offer|default:"0" }}
    • +
    • {% trans "Form" %}:{% if job.form_template %} + {{ job.form_template.name }} + {% else %} + {% trans "N/A" %} + {% endif %} +
    • +
    + +
    + + {% trans "Details" %} + +
    + + + + {% if job.form_template %} + + + + {% endif %} +
    +
    +
    +
    +
    + {% endfor %} +
    + {# --- END CARD VIEW --- #} + {# --- END OF JOB LIST CONTAINER --- #} {% comment %} Fallback/Empty State {% endcomment %} {% if not jobs and not job_list_data and not page_obj %} diff --git a/templates/recruitment/candidate_screening_view.html b/templates/recruitment/candidate_screening_view.html index 8ce3eed..ee5970c 100644 --- a/templates/recruitment/candidate_screening_view.html +++ b/templates/recruitment/candidate_screening_view.html @@ -172,7 +172,12 @@ {% endblock %} {% block content %} +
    +
    + {% include 'jobs/partials/applicant_tracking.html' %} +
    +

    @@ -189,16 +194,14 @@

    -
    - {% include 'jobs/partials/applicant_tracking.html' %} -
    +

    {% trans "AI Scoring & Top Candidate Filter" %}

    -
    + {% csrf_token %}
    @@ -207,7 +210,7 @@ {% trans "Min AI Score" %}
    diff --git a/templates/recruitment/training_create.html b/templates/recruitment/training_create.html index a09f19d..b4732ad 100644 --- a/templates/recruitment/training_create.html +++ b/templates/recruitment/training_create.html @@ -24,7 +24,7 @@ /* Main Action Button Style */ .btn-main-action{ - background-color: var(--kaauh-teal-dark); /* Changed to primary teal for main actions */ + background-color: gray; /* Changed to primary teal for main actions */ border-color: var(--kaauh-teal); color: white; font-weight: 600;
    {% trans "Job ID" %}{% trans "Job Title" %}{% trans "Status" %}{% trans "Source" %}{% trans "Actions" %}{% trans "Manage Forms" %}{% trans "Job Title / ID" %}{% trans "Source" %}{% trans "Actions" %}{% trans "Manage Forms" %} + {% trans "Applicants Metrics" %} - - - - - - - - - - - - -
    {% trans "Applied" %}{% trans "Screened" %}{% trans "Exam" %} - - - - - -
    PF
    -
    {% trans "Interview" %} - - - - - -
    PF
    -
    {% trans "Offer" %}
    {% trans "Applied" %}{% trans "Screened" %}{% trans "Exam" %} +
    + P + F +
    +
    {% trans "Interview" %} +
    + P + F +
    +
    {% trans "Offer" %}
    {{ job }}{{ job.title }}{{ job.status }} + {{ job.title }} +
    + {{ job.pk }} / + {{ job.status }} +
    {{ job.get_source }}
    @@ -283,10 +306,10 @@
    +
    {% if job.form_template %} - + @@ -299,7 +322,7 @@
    {% if job.metrics.applied %}{{ job.metrics.applied }}{% else %}-{% endif %} {% if job.metrics.screening %}{{ job.metrics.screening }}{% else %}-{% endif %} {% if job.metrics.exam_p %}{{ job.metrics.exam_p }}{% else %}-{% endif %}