Compare commits
2 Commits
bb430cf049
...
038a18cacb
| Author | SHA1 | Date | |
|---|---|---|---|
| 038a18cacb | |||
| e9c76dfe18 |
6
.env
6
.env
@ -1,3 +1,3 @@
|
||||
DB_NAME=haikal_db
|
||||
DB_USER=faheed
|
||||
DB_PASSWORD=Faheed@215
|
||||
DB_NAME=norahuniversity
|
||||
DB_USER=norahuniversity
|
||||
DB_PASSWORD=norahuniversity
|
||||
@ -209,8 +209,19 @@ ACCOUNT_FORMS = {"signup": "recruitment.forms.StaffSignupForm"}
|
||||
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
EMAIL_HOST = "10.10.1.110"
|
||||
EMAIL_PORT = 2225
|
||||
# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
# EMAIL_HOST_PASSWORD = os.getenv("EMAIL_PASSWORD", "mssp.0Q0rSwb.zr6ke4n2k3e4on12.aHwJqnI")
|
||||
# EMAIL_HOST = "smtp.mailersend.net"
|
||||
# EMAIL_PORT = 2525
|
||||
# EMAIL_HOST_USER = "MS_lhygCJ@test-65qngkd8nx3lwr12.mlsender.net"
|
||||
# EMAIL_HOST_PASSWORD = "mssp.0Q0rSwb.zr6ke4n2k3e4on12.aHwJqnI"
|
||||
# EMAIL_USE_TLS = True
|
||||
EMAIL_HOST = 'sandbox.smtp.mailtrap.io'
|
||||
EMAIL_HOST_USER = '38e5179debe69a'
|
||||
EMAIL_HOST_PASSWORD = 'ffa75647d01ecb'
|
||||
EMAIL_PORT = '2525'
|
||||
|
||||
|
||||
# Crispy Forms Configuration
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
|
||||
@ -276,11 +287,11 @@ SOCIALACCOUNT_PROVIDERS = {
|
||||
|
||||
# Dynamic Zoom Configuration - will be loaded from database
|
||||
# These are fallback values - actual values will be loaded from database at runtime
|
||||
ZOOM_ACCOUNT_ID = "HoGikHXsQB2GNDC5Rvyw9A"
|
||||
ZOOM_CLIENT_ID = "brC39920R8C8azfudUaQgA"
|
||||
ZOOM_CLIENT_SECRET = "rvfhjlbID4ychXPOvZ2lYsoAC0B0Ny2L"
|
||||
SECRET_TOKEN = "6KdTGyF0SSCSL_V4Xa34aw"
|
||||
ZOOM_WEBHOOK_API_KEY = "2GNDC5Rvyw9AHoGikHXsQB"
|
||||
# ZOOM_ACCOUNT_ID = "HoGikHXsQB2GNDC5Rvyw9A"
|
||||
# ZOOM_CLIENT_ID = "brC39920R8C8azfudUaQgA"
|
||||
# ZOOM_CLIENT_SECRET = "rvfhjlbID4ychXPOvZ2lYsoAC0B0Ny2L"
|
||||
# SECRET_TOKEN = "6KdTGyF0SSCSL_V4Xa34aw"
|
||||
# ZOOM_WEBHOOK_API_KEY = "2GNDC5Rvyw9AHoGikHXsQB"
|
||||
|
||||
# Maximum file upload size (in bytes)
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
|
||||
|
||||
@ -58,14 +58,12 @@ class SourceForm(forms.ModelForm):
|
||||
"name": forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "e.g., ATS System, ERP Integration",
|
||||
"required": True,
|
||||
}
|
||||
),
|
||||
"source_type": forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "e.g., ATS, ERP, API",
|
||||
"required": True,
|
||||
}
|
||||
),
|
||||
@ -73,16 +71,15 @@ class SourceForm(forms.ModelForm):
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"rows": 3,
|
||||
"placeholder": "Brief description of the source system",
|
||||
}
|
||||
),
|
||||
"ip_address": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "192.168.1.100",
|
||||
attrs={"class": "form-control",
|
||||
"required":True},
|
||||
|
||||
),
|
||||
"trusted_ips":forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "192.168.1.100","required": False}
|
||||
attrs={"class": "form-control", "required": False}
|
||||
),
|
||||
"is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
}
|
||||
@ -318,7 +315,7 @@ class PersonForm(forms.ModelForm):
|
||||
pass
|
||||
|
||||
return email.strip()
|
||||
|
||||
|
||||
|
||||
|
||||
class ApplicationForm(forms.ModelForm):
|
||||
@ -1930,6 +1927,52 @@ class ScheduledInterviewForm(forms.Form):
|
||||
raise forms.ValidationError(_('Start time cannot be in the past.'))
|
||||
return start_time
|
||||
|
||||
class OnsiteScheduleInterviewUpdateForm(forms.Form):
|
||||
topic = forms.CharField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'e.g., Interview Topic'
|
||||
}),
|
||||
label=_('Interview Topic')
|
||||
)
|
||||
start_time = forms.DateTimeField(
|
||||
widget=forms.DateTimeInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'datetime-local',
|
||||
'required': True
|
||||
}),
|
||||
label=_('Start Time')
|
||||
)
|
||||
duration = forms.IntegerField(
|
||||
min_value=1,
|
||||
required=False,
|
||||
widget=forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Duration in minutes'
|
||||
}),
|
||||
label=_('Duration (minutes)')
|
||||
)
|
||||
physical_address = forms.CharField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Physical address'
|
||||
}),
|
||||
label=_('Physical Address')
|
||||
)
|
||||
room_number = forms.CharField(
|
||||
max_length=50,
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Room number'
|
||||
}),
|
||||
label=_('Room Number')
|
||||
)
|
||||
|
||||
class ScheduledInterviewUpdateStatusForm(forms.Form):
|
||||
status = forms.ChoiceField(
|
||||
choices=ScheduledInterview.InterviewStatus.choices,
|
||||
@ -2034,7 +2077,7 @@ class SettingsForm(forms.ModelForm):
|
||||
class InterviewEmailForm(forms.Form):
|
||||
"""Form for composing emails to participants about a candidate"""
|
||||
to = forms.CharField(
|
||||
|
||||
|
||||
label=_('To'), # Use a descriptive label
|
||||
required=True,
|
||||
|
||||
@ -2069,12 +2112,12 @@ class InterviewEmailForm(forms.Form):
|
||||
if application.hiring_agency:
|
||||
self.fields['to'].initial=application.hiring_agency.email
|
||||
self.fields['to'].disabled= True
|
||||
|
||||
|
||||
|
||||
|
||||
else:
|
||||
self.fields['to'].initial=application.person.email
|
||||
self.fields['to'].disabled= True
|
||||
|
||||
|
||||
|
||||
|
||||
# Set initial message with candidate and meeting info
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-11 14:18
|
||||
# Generated by Django 6.0 on 2025-12-12 11:17
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0 on 2025-12-12 11:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='source',
|
||||
name='name',
|
||||
field=models.CharField(help_text='Name of the source', max_length=100, unique=True, verbose_name='Source Name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='source',
|
||||
name='source_type',
|
||||
field=models.CharField(help_text='Type of the source', max_length=100, verbose_name='Source Type'),
|
||||
),
|
||||
]
|
||||
@ -1748,10 +1748,10 @@ class Source(Base):
|
||||
max_length=100,
|
||||
unique=True,
|
||||
verbose_name=_("Source Name"),
|
||||
help_text=_("e.g., ATS, ERP "),
|
||||
help_text=_("Name of the source"),
|
||||
)
|
||||
source_type = models.CharField(
|
||||
max_length=100, verbose_name=_("Source Type"), help_text=_("e.g., ATS, ERP ")
|
||||
max_length=100, verbose_name=_("Source Type"), help_text=_("Type of the source")
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
|
||||
@ -12,6 +12,7 @@ from . linkedin_service import LinkedInService
|
||||
from django.shortcuts import get_object_or_404
|
||||
from . models import JobPosting
|
||||
from django.utils import timezone
|
||||
from django.template.loader import render_to_string
|
||||
from . models import BulkInterviewTemplate,Interview,Message,ScheduledInterview
|
||||
from django.contrib.auth import get_user_model
|
||||
from .utils import get_setting
|
||||
@ -680,7 +681,6 @@ def create_interview_and_meeting(schedule_id):
|
||||
try:
|
||||
schedule = ScheduledInterview.objects.get(pk=schedule_id)
|
||||
interview = schedule.interview
|
||||
|
||||
result = create_zoom_meeting(interview.topic, interview.start_time, interview.duration)
|
||||
|
||||
if result["status"] == "success":
|
||||
@ -714,7 +714,6 @@ def handle_zoom_webhook_event(payload):
|
||||
# Zoom often uses a long 'id' for the scheduled meeting and sometimes a 'uuid'.
|
||||
# We rely on the unique 'id' that maps to your ZoomMeeting.meeting_id field.
|
||||
meeting_id_zoom = str(object_data.get('id'))
|
||||
print(meeting_id_zoom)
|
||||
if not meeting_id_zoom:
|
||||
logger.warning(f"Webhook received without a valid Meeting ID: {event_type}")
|
||||
return False
|
||||
@ -964,57 +963,155 @@ from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
def _task_send_individual_email(subject, body_message, recipient, attachments,sender,job):
|
||||
"""Internal helper to create and send a single email."""
|
||||
# def _task_send_individual_email(subject, body_message, recipient, attachments,sender,job):
|
||||
# """Internal helper to create and send a single email."""
|
||||
|
||||
|
||||
# from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
|
||||
# is_html = '<' in body_message and '>' in body_message
|
||||
|
||||
# if is_html:
|
||||
# plain_message = strip_tags(body_message)
|
||||
# email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient])
|
||||
# email_obj.attach_alternative(body_message, "text/html")
|
||||
# else:
|
||||
# email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
|
||||
|
||||
# if attachments:
|
||||
# for attachment in attachments:
|
||||
# if isinstance(attachment, tuple) and len(attachment) == 3:
|
||||
# filename, content, content_type = attachment
|
||||
# email_obj.attach(filename, content, content_type)
|
||||
|
||||
# try:
|
||||
# result=email_obj.send(fail_silently=False)
|
||||
|
||||
# if result==1 and sender and job: # job is none when email sent after message creation
|
||||
|
||||
# try:
|
||||
# user=get_object_or_404(User,email=recipient)
|
||||
# new_message = Message.objects.create(
|
||||
# sender=sender,
|
||||
# recipient=user,
|
||||
# job=job,
|
||||
# subject=subject,
|
||||
# content=body_message, # Store the full HTML or plain content
|
||||
# message_type='DIRECT',
|
||||
# is_read=False, # It's just sent, not read yet
|
||||
# )
|
||||
# logger.info(f"Stored sent message ID {new_message.id} in DB.")
|
||||
# except Exception as e:
|
||||
# logger.error(f"Email sent to {recipient}, but failed to store in DB: {str(e)}")
|
||||
# return result == 1
|
||||
|
||||
# except Exception as e:
|
||||
# logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
|
||||
|
||||
def _task_send_individual_email(subject, body_message, recipient, attachments=None, sender=None, job=None, context=None):
|
||||
"""
|
||||
Creates and sends a single email using the branded HTML template.
|
||||
If the context is provided, it renders the branded template.
|
||||
If the context is None, it sends the plain body_message.
|
||||
|
||||
Args:
|
||||
subject (str): The email subject.
|
||||
body_message (str): The main content of the email.
|
||||
recipient (str): The recipient's email address.
|
||||
attachments (list, optional): List of (filename, content, mimetype) tuples.
|
||||
sender (User, optional): The User object who initiated the send.
|
||||
job (Job, optional): The associated Job object (if any).
|
||||
context (dict, optional): Context data for rendering the HTML template.
|
||||
|
||||
Returns:
|
||||
bool: True if the email was successfully sent and logged, False otherwise.
|
||||
"""
|
||||
|
||||
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
|
||||
is_html = '<' in body_message and '>' in body_message
|
||||
|
||||
if is_html:
|
||||
plain_message = strip_tags(body_message)
|
||||
email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient])
|
||||
email_obj.attach_alternative(body_message, "text/html")
|
||||
# --- 1. Template Rendering (New Logic) ---
|
||||
if context:
|
||||
# 1a. Populate the base context required by the branded template
|
||||
base_context = {
|
||||
'subject': subject,
|
||||
'user_name': context.pop('user_name', recipient), # Expect user_name from context or default to email
|
||||
'email_message': body_message,
|
||||
'user_email': recipient,
|
||||
'logo_url': context.pop('logo_url', settings.MEDIA_URL + '/images/kaauh-logo.png'),
|
||||
# Merge any other custom context variables
|
||||
**context,
|
||||
}
|
||||
|
||||
try:
|
||||
html_content = render_to_string('emails/email_template.html', base_context)
|
||||
plain_message = strip_tags(html_content)
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering HTML template for {recipient}. Sending plain text instead. Error: {e}")
|
||||
html_content = None
|
||||
plain_message = body_message # Fallback to the original body_message
|
||||
else:
|
||||
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
|
||||
# Use the original body_message as the plain text body
|
||||
html_content = None
|
||||
plain_message = body_message
|
||||
|
||||
|
||||
# --- 2. Create Email Object ---
|
||||
email_obj = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=plain_message, # Always use plain text for the main body
|
||||
from_email=from_email,
|
||||
to=[recipient]
|
||||
)
|
||||
|
||||
# Attach HTML alternative if rendered successfully
|
||||
if html_content:
|
||||
email_obj.attach_alternative(html_content, "text/html")
|
||||
|
||||
# --- 3. Attachments ---
|
||||
if attachments:
|
||||
for attachment in attachments:
|
||||
if isinstance(attachment, tuple) and len(attachment) == 3:
|
||||
filename, content, content_type = attachment
|
||||
email_obj.attach(filename, content, content_type)
|
||||
|
||||
# --- 4. Send and Log ---
|
||||
try:
|
||||
result=email_obj.send(fail_silently=False)
|
||||
|
||||
if result==1 and sender and job: # job is none when email sent after message creation
|
||||
# Note: EmailMultiAlternatives inherits from EmailMessage and uses .send()
|
||||
result = email_obj.send(fail_silently=False)
|
||||
|
||||
if result == 1 and sender and job: # job is None when email sent after message creation
|
||||
# --- Assuming Message and User are available ---
|
||||
try:
|
||||
user=get_object_or_404(User,email=recipient)
|
||||
# IMPORTANT: You need to define how to find the User object from the recipient email.
|
||||
# Assuming you have access to the User model and get_object_or_404
|
||||
# User = ... # Define or import your User model
|
||||
# Message = ... # Define or import your Message model
|
||||
|
||||
user = User.objects.get(email=recipient)
|
||||
new_message = Message.objects.create(
|
||||
sender=sender,
|
||||
recipient=user,
|
||||
job=job,
|
||||
subject=subject,
|
||||
content=body_message, # Store the full HTML or plain content
|
||||
content=html_content or body_message, # Store HTML if sent, otherwise store original body
|
||||
message_type='DIRECT',
|
||||
is_read=False, # It's just sent, not read yet
|
||||
is_read=False,
|
||||
)
|
||||
logger.info(f"Stored sent message ID {new_message.id} in DB.")
|
||||
logger.info(f"Stored sent message ID {new_message.id} for {recipient} in DB.")
|
||||
except Exception as e:
|
||||
logger.error(f"Email sent to {recipient}, but failed to store in DB: {str(e)}")
|
||||
return result == 1
|
||||
logger.error(f"Email sent successfully to {recipient}, but failed to store message in DB: {str(e)}")
|
||||
# Continue execution even if logging fails, as the email was sent
|
||||
|
||||
return result == 1 # Return True if send was successful
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
|
||||
|
||||
return False
|
||||
|
||||
def send_bulk_email_task(subject, customized_sends,attachments=None,sender_user_id=None,job_id=None, hook='recruitment.tasks.email_success_hook'):
|
||||
"""
|
||||
Django-Q background task to send pre-formatted email to a list of recipients.,
|
||||
Receives arguments directly from the async_task call.
|
||||
"""
|
||||
print("jhjmfhsdjhfksjhdkfjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjh")
|
||||
logger.info(f"Starting bulk email task for {len(customized_sends)} recipients")
|
||||
successful_sends = 0
|
||||
total_recipients = len(customized_sends)
|
||||
|
||||
@ -426,6 +426,7 @@ def create_zoom_meeting(topic, start_time, duration):
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
ZOOM_MEETING_URL = get_setting('ZOOM_MEETING_URL')
|
||||
print(ZOOM_MEETING_URL)
|
||||
response = requests.post(
|
||||
ZOOM_MEETING_URL,
|
||||
headers=headers,
|
||||
|
||||
@ -187,7 +187,7 @@ class PersonListView(StaffRequiredMixin, ListView, LoginRequiredMixin):
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().select_related("user")
|
||||
search_query = self.request.GET.get("search", "")
|
||||
print(Person.objects.first().last_name)
|
||||
|
||||
if search_query:
|
||||
queryset=queryset.filter(
|
||||
Q(first_name=search_query) |
|
||||
@ -226,10 +226,10 @@ class PersonCreateView(CreateView, LoginRequiredMixin, StaffOrAgencyRequiredMixi
|
||||
template_name = "people/create_person.html"
|
||||
form_class = PersonForm
|
||||
success_url = reverse_lazy("person_list")
|
||||
|
||||
|
||||
|
||||
def form_valid(self, form):
|
||||
|
||||
|
||||
instance = form.save()
|
||||
view = self.request.POST.get("view")
|
||||
if view == "portal":
|
||||
@ -239,8 +239,8 @@ class PersonCreateView(CreateView, LoginRequiredMixin, StaffOrAgencyRequiredMixi
|
||||
print(agency)
|
||||
instance.agency = agency
|
||||
instance.save()
|
||||
|
||||
|
||||
|
||||
|
||||
# 2. Add the content to update (e.g., re-render the person list table)
|
||||
# response.content = render_to_string('recruitment/persons_table.html',
|
||||
return redirect("agency_portal_persons_list")
|
||||
@ -1910,8 +1910,7 @@ def applications_screening_view(request, slug):
|
||||
ai_analysis_data__analysis_data_en__screening_stage_rating=screening_rating
|
||||
)
|
||||
if gpa:
|
||||
applications = applications.filter(person__gpa__gt=gpa)
|
||||
print(applications)
|
||||
applications = applications.filter(person__gpa__gte=gpa)
|
||||
|
||||
if tier1_count > 0:
|
||||
applications = applications[:tier1_count]
|
||||
@ -2131,27 +2130,43 @@ def applications_document_review_view(request, slug):
|
||||
@staff_user_required
|
||||
def reschedule_meeting_for_application(request, slug):
|
||||
from .utils import update_meeting
|
||||
from .forms import OnsiteScheduleInterviewUpdateForm
|
||||
|
||||
schedule = get_object_or_404(ScheduledInterview, slug=slug)
|
||||
interview = schedule.interview
|
||||
|
||||
if request.method == "POST":
|
||||
form = ScheduledInterviewForm(request.POST)
|
||||
if interview.location_type == "Remote":
|
||||
form = ScheduledInterviewForm(request.POST)
|
||||
else:
|
||||
form = OnsiteScheduleInterviewUpdateForm(request.POST)
|
||||
if form.is_valid():
|
||||
topic = form.cleaned_data.get("topic")
|
||||
start_time = form.cleaned_data.get("start_time")
|
||||
duration = form.cleaned_data.get("duration")
|
||||
updated_data = {
|
||||
"topic": topic,
|
||||
"start_time": start_time.isoformat() + "Z",
|
||||
"duration": duration,
|
||||
}
|
||||
result = update_meeting(schedule.interview, updated_data)
|
||||
physical_address = form.cleaned_data.get("physical_address")
|
||||
room_number = form.cleaned_data.get("room_number")
|
||||
if interview.location_type == "Remote":
|
||||
updated_data = {
|
||||
"topic": topic,
|
||||
"start_time": start_time.isoformat() + "Z",
|
||||
"duration": duration,
|
||||
}
|
||||
result = update_meeting(schedule.interview, updated_data)
|
||||
|
||||
if result["status"] == "success":
|
||||
messages.success(request, result["message"])
|
||||
if result["status"] == "success":
|
||||
messages.success(request, result["message"])
|
||||
else:
|
||||
messages.error(request, result["message"])
|
||||
else:
|
||||
messages.error(request, result["message"])
|
||||
interview.topic = topic
|
||||
interview.start_time = start_time
|
||||
interview.duration = duration
|
||||
interview.room_number = room_number
|
||||
interview.physical_address = physical_address
|
||||
interview.save()
|
||||
messages.success(request, "Meeting updated successfully")
|
||||
else:
|
||||
print(form.errors)
|
||||
messages.error(request, "Invalid data submitted.")
|
||||
return redirect("interview_detail", slug=schedule.slug)
|
||||
|
||||
@ -3119,7 +3134,7 @@ def agency_portal_persons_list(request):
|
||||
| Q(last_name__icontains=search_query)
|
||||
| Q(email__icontains=search_query)
|
||||
| Q(phone=search_query)
|
||||
|
||||
|
||||
)
|
||||
|
||||
paginator = Paginator(persons, 20) # Show 20 persons per page
|
||||
@ -4395,7 +4410,7 @@ def compose_application_email(request, slug):
|
||||
request=request,
|
||||
attachments=None,
|
||||
async_task_=True, # Changed to False to avoid pickle issues
|
||||
from_interview=False,
|
||||
# from_interview=False,
|
||||
job=job,
|
||||
)
|
||||
|
||||
@ -4791,14 +4806,23 @@ def interview_list(request):
|
||||
@staff_user_required
|
||||
def interview_detail(request, slug):
|
||||
"""View details of a specific interview"""
|
||||
from .forms import ScheduledInterviewUpdateStatusForm
|
||||
from .forms import ScheduledInterviewUpdateStatusForm,OnsiteScheduleInterviewUpdateForm
|
||||
|
||||
schedule = get_object_or_404(ScheduledInterview, slug=slug)
|
||||
interview = schedule.interview
|
||||
application=schedule.application
|
||||
job=schedule.job
|
||||
reschedule_form = ScheduledInterviewForm()
|
||||
print(interview.location_type)
|
||||
if interview.location_type == "Remote":
|
||||
reschedule_form = ScheduledInterviewForm()
|
||||
else:
|
||||
reschedule_form = OnsiteScheduleInterviewUpdateForm()
|
||||
reschedule_form.initial['physical_address'] = interview.physical_address
|
||||
reschedule_form.initial['room_number'] = interview.room_number
|
||||
reschedule_form.initial['topic'] = interview.topic
|
||||
reschedule_form.initial['start_time'] = interview.start_time
|
||||
reschedule_form.initial['duration'] = interview.duration
|
||||
|
||||
meeting=interview
|
||||
interview_email_form=InterviewEmailForm(job,application,schedule)
|
||||
context = {
|
||||
@ -5396,14 +5420,14 @@ class ApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
||||
super()
|
||||
.get_queryset()
|
||||
.select_related("person", "job")
|
||||
|
||||
|
||||
)
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get("search", "")
|
||||
job = self.request.GET.get("job", "")
|
||||
stage = self.request.GET.get("stage", "")
|
||||
|
||||
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(person__first_name=search_query) |
|
||||
@ -6109,9 +6133,9 @@ STAGE_CONFIG = {
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def export_applications_csv(request, job_slug, stage):
|
||||
def export_applications_csv(request, slug, stage):
|
||||
"""Export applications for a specific stage as CSV"""
|
||||
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
|
||||
# Validate stage
|
||||
if stage not in STAGE_CONFIG:
|
||||
|
||||
4
run.py
4
run.py
@ -227,10 +227,10 @@ if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Translate .po files using AI Providers (Z.ai, Ollama, OpenAI)")
|
||||
|
||||
parser.add_argument('path', type=str, help='Path to the .po file')
|
||||
parser.add_argument('--lang', type=str, required=True, help='Target language (e.g., "French", "zh-CN")')
|
||||
parser.add_argument('--lang', type=str, required=True, default='ar', help='Target language (e.g., "French", "zh-CN")')
|
||||
|
||||
# Provider Settings
|
||||
parser.add_argument('--provider', type=str, default='glm', choices=['glm', 'ollama', 'openai'], help='AI Provider to use')
|
||||
parser.add_argument('--provider', type=str, default='ollama', choices=['glm', 'ollama', 'openai'], help='AI Provider to use')
|
||||
parser.add_argument('--model', type=str, help='Model name (e.g., glm-4, llama3, gpt-4). Defaults vary by provider.')
|
||||
parser.add_argument('--api-key', type=str, help='API Key (optional if env var is set)')
|
||||
parser.add_argument('--api-base', type=str, help='Custom API Base URL (useful for custom Ollama ports)')
|
||||
|
||||
@ -324,7 +324,7 @@
|
||||
|
||||
|
||||
|
||||
<li class="nav-item mx-2 mb-1">
|
||||
{% comment %} <li class="nav-item mx-2 mb-1">
|
||||
{% if request.user.user_type == 'candidate' and request.user.is_authenticated and request.user.profile_image.url %}
|
||||
<a href="{% url 'applicant_portal_dashboard' %}" class="mx-2">
|
||||
<img src="{{ request.user.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar"
|
||||
@ -334,43 +334,104 @@
|
||||
{% else %}
|
||||
<a class="nav-link text-primary-theme" href="{% url 'applicant_portal_dashboard' %}">{% trans "Profile" %}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li class="nav-item mx-2 mb-1">
|
||||
<a class="nav-link text-secondary text-primary-theme" href="{% url 'kaauh_career' %}">{% trans "Careers" %}</a>
|
||||
</li>
|
||||
</li> {% endcomment %}
|
||||
{% if request.resolver_match.url_name != "kaauh_career" %}
|
||||
<li class="nav-item mx-2 mb-1">
|
||||
<a class="nav-link text-secondary text-primary-theme" href="{% url 'kaauh_career' %}">{% trans "Careers" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<li class="nav-item me-2 d-none d-lg-block">
|
||||
{% if LANGUAGE_CODE == 'en' %}
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="ar" class="btn bg-primary-theme text-primary-theme" type="submit">
|
||||
<span class="me-2">🇸🇦</span> العربية
|
||||
</button>
|
||||
</form>
|
||||
{% elif LANGUAGE_CODE == 'ar' %}
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="en" class="btn bg-primary-theme text-primary-theme" type="submit">
|
||||
<span class="me-2">🇺🇸</span> English
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<button class="language-toggle-btn dropdown-toggle" type="button"
|
||||
data-bs-toggle="dropdown" data-bs-offset="0, 8" aria-expanded="false"
|
||||
aria-label="{% trans 'Toggle language menu' %}">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span class="d-inline">{{ LANGUAGE_CODE|upper }}</span>
|
||||
|
||||
<span class="d-inline"></span>
|
||||
{% if user.profile_image %}
|
||||
<img src="{{ user.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar"
|
||||
style="width: 36px; height: 36px; object-fit: cover; background-color: var(--kaauh-teal); display: inline-block; vertical-align: middle;"
|
||||
title="{% trans 'Your account' %}">
|
||||
{% else %}
|
||||
<div class="profile-avatar" title="{% trans 'Your account' %}">
|
||||
{% if user.first_name %}
|
||||
{{ user.first_name|first|capfirst }} {{ user.last_name|first|capfirst }}
|
||||
{% else %}
|
||||
{{user.username|first|capfirst}}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu mx-auto {% if LANGUAGE_CODE == 'ar' %}dropdown-menu-end{% else %}dropdown-menu-end{% endif %}" aria-labelledby="navbarLanguageDropdown">
|
||||
<ul class="dropdown-menu dropdown-menu-end py-0 shadow border-0 rounded-3" style="min-width: 240px;">
|
||||
<li class="px-4 py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3 d-flex align-items-center justify-content-center" style="min-width: 48px;">
|
||||
{% if user.profile_image %}
|
||||
<img src="{{ user.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar shadow-sm border"
|
||||
style="width: 44px; height: 44px; object-fit: cover; background-color: var(--kaauh-teal); display: block;"
|
||||
title="{% trans 'Your account' %}">
|
||||
{% else %}
|
||||
<div class="profile-avatar shadow-sm border d-flex align-items-center justify-content-center text-primary-theme"
|
||||
style="width: 44px; height: 44px; background-color: var(--kaauh-teal); font-size: 1.2rem;">
|
||||
{{ user.username|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold text-dark">{{ user.get_full_name|default:user.username }}</div>
|
||||
<div class="text-muted small">{{ user.email|truncatechars:24 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="en" class="dropdown-item {% if LANGUAGE_CODE == 'en' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="flag-emoji">🇺🇸</span>
|
||||
<span class="language-text">English</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="ar" class="dropdown-item {% if LANGUAGE_CODE == 'ar' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="flag-emoji">🇸🇦</span>
|
||||
<span class="language-text">العربية (Arabic)</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none text-primary-theme" href="{% url 'applicant_portal_dashboard' %}">
|
||||
<i class="fas fa-tachometer-alt me-3 fs-5"></i> <span>{% trans "Dashboard" %}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none text-primary-theme" href="{% url 'user_detail' request.user.pk %}">
|
||||
<i class="fas fa-user-circle me-3 fs-5"></i> <span>{% trans "My Profile" %}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<li>
|
||||
<form method="post" action="{% url 'account_logout'%}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button
|
||||
type="submit"
|
||||
class="dropdown-item py-2 px-4 d-flex align-items-center border-0 bg-transparent text-start w-100"
|
||||
aria-label="{% trans 'Sign out' %}"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt me-3 fs-5" style="color:red;"></i>
|
||||
<span style="color:red;">{% trans "Sign Out" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -374,8 +374,8 @@
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap max-width-1600">
|
||||
<p class="mb-0 text-white-50">
|
||||
© {% now "Y" %} {% trans "King Abdullah Academic University Hospital (KAAUH)." %}
|
||||
{% trans "All rights reserved." %}
|
||||
{% comment %} © {% now "Y" %} {% trans "King Abdullah Academic University Hospital (KAAUH)." %}
|
||||
{% trans "All rights reserved." %} {% endcomment %}
|
||||
</p>
|
||||
<a class="text-decoration-none" href="https://tenhal.sa/" target='_blank'>
|
||||
<p class="mb-0 text-white-50">
|
||||
|
||||
118
templates/emails/email_template.html
Normal file
118
templates/emails/email_template.html
Normal file
@ -0,0 +1,118 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ subject }}</title>
|
||||
<style>
|
||||
/* Define your custom colors */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
}
|
||||
|
||||
/* General Styling */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f4f4;
|
||||
font-family: Arial, sans-serif;
|
||||
color: #333333;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #dddddd;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05); /* Soft shadow */
|
||||
}
|
||||
|
||||
/* Header Section */
|
||||
.header {
|
||||
background-color: #00636e; /* --kaauh-teal */
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.logo {
|
||||
max-width: 80px;
|
||||
height: auto;
|
||||
border: 2px solid #ffffff; /* White border to make it pop */
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Content Section */
|
||||
.content {
|
||||
padding: 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h2 {
|
||||
color: #004a53; /* --kaauh-teal-dark for headings */
|
||||
}
|
||||
|
||||
/* Button/Call to Action */
|
||||
.button-container {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 25px;
|
||||
background-color: #00636e; /* --kaauh-teal */
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
/* Simple hover simulation for supporting clients */
|
||||
border-bottom: 4px solid #004a53;
|
||||
}
|
||||
|
||||
/* Footer Section */
|
||||
.footer {
|
||||
background-color: #f0f0f0;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #777777;
|
||||
border-top: 2px solid #00636e;
|
||||
}
|
||||
.footer a {
|
||||
color: #00636e; /* --kaauh-teal for links */
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{{ logo_url }}" alt="Your Organization Logo" class="logo">
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{% block content %}
|
||||
<h2>Hello {{ user_name }},</h2>
|
||||
|
||||
<p>{{ email_message|safe }}</p>
|
||||
|
||||
{% if cta_link %}
|
||||
<div class="button-container">
|
||||
<a href="{{ cta_link }}" class="button">{{ cta_text|default:"Click to Proceed" }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p>If you have any questions, please reply to this email.</p>
|
||||
|
||||
<p>Thank you,</p>
|
||||
<p>The **[Your Organization Name]** Team</p>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© {% now "Y" %} Your Organization Name. All rights reserved.</p>
|
||||
<p>This email was sent to {{ user_email }}.</p>
|
||||
<p><a href="{{ unsubscribe_link }}">Unsubscribe</a> | <a href="{{ preferences_link }}">Manage Preferences</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -223,7 +223,7 @@
|
||||
{% block customJS %}
|
||||
<script>
|
||||
// Auto-refresh unread count every 30 seconds
|
||||
setInterval(() => {
|
||||
/*setInterval(() => {
|
||||
fetch('/api/unread-count/')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
@ -236,5 +236,6 @@ setInterval(() => {
|
||||
})
|
||||
.catch(error => console.error('Error fetching unread count:', error));
|
||||
}, 30000);
|
||||
*/
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -45,11 +45,15 @@
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted d-block mb-1">{% trans "From:" %}</small>
|
||||
<span class="fw-semibold">{{ message.sender.get_full_name|default:message.sender.username }}</span>
|
||||
<span class="fw-semibold">{{ message.sender.get_full_name|default:message.sender.username }} <br>
|
||||
<span class="text-muted" style="font-size: 0.8em">{{ message.sender.email }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted d-block mb-1">{% trans "To:" %}</small>
|
||||
<span class="fw-semibold">{{ message.recipient.get_full_name|default:message.recipient.username }}</span>
|
||||
<span class="fw-semibold">{{ message.recipient.get_full_name|default:message.recipient.username }} <br>
|
||||
<span class="text-muted" style="font-size: 0.8em">{{ message.recipient.email }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted d-block mb-1">{% trans "Type:" %}</small>
|
||||
@ -75,9 +79,13 @@
|
||||
{% if message.job %}
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted d-block mb-1">{% trans "Related Job:" %}</small>
|
||||
<a href="{% url 'job_detail' message.job.slug %}" class="fw-semibold text-decoration-none text-primary-theme">
|
||||
{% if request.user.user_type == "staff" %}
|
||||
<a href="{% url 'job_detail' message.job.slug %}" class="fw-semibold text-decoration-none text-primary-theme">
|
||||
{{ message.job.title }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ message.job.title }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@ -116,7 +116,8 @@
|
||||
{% if message.sender == request.user %}
|
||||
{% trans "Me"%}
|
||||
{% else %}
|
||||
{{ message.sender.get_full_name|default:message.sender.username }}
|
||||
{{ message.sender.get_full_name|default:message.sender.username }} <br>
|
||||
<span class="text-muted" style="font-size: 0.8em">{{ message.sender.email }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
@ -124,6 +125,8 @@
|
||||
{% trans "Me"%}
|
||||
{% else %}
|
||||
{{ message.recipient.get_full_name|default:message.recipient.username }}
|
||||
<br>
|
||||
<span class="text-muted" style="font-size: 0.8em">{{ message.recipient.email }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
@ -232,7 +235,7 @@
|
||||
{% block customJS %}
|
||||
<script>
|
||||
// Auto-refresh unread count every 30 seconds
|
||||
setInterval(() => {
|
||||
/*setInterval(() => {
|
||||
fetch('/api/unread-count/')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
@ -245,5 +248,6 @@ setInterval(() => {
|
||||
})
|
||||
.catch(error => console.error('Error fetching unread count:', error));
|
||||
}, 30000);
|
||||
*/
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -252,8 +252,8 @@
|
||||
{% trans "GPA" %}
|
||||
</label>
|
||||
<input type="number" name="GPA" id="gpa" class="form-control form-control-sm"
|
||||
value="{{ gpa }}" min="0" max="4"
|
||||
placeholder="e.g., 4" style="width: 120px;">
|
||||
value="{{ gpa }}" step="0.01" min="0" max="4"
|
||||
placeholder="e.g., 3.5" style="width: 120px;">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label for="min_ai_score" class="form-label small text-muted mb-1">
|
||||
|
||||
@ -192,7 +192,7 @@
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
/*document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-refresh notification count every 30 seconds
|
||||
setInterval(function() {
|
||||
fetch('/api/notification-count/')
|
||||
@ -212,5 +212,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
.catch(error => console.error('Error fetching notifications:', error));
|
||||
}, 30000);
|
||||
});
|
||||
*/
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -207,6 +207,7 @@
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
/*
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-refresh notifications every 30 seconds
|
||||
setInterval(function() {
|
||||
@ -227,5 +228,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
.catch(error => console.error('Error fetching notifications:', error));
|
||||
}, 30000);
|
||||
});
|
||||
*/
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -64,14 +64,6 @@
|
||||
<!-- Settings Table -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if page_obj %}
|
||||
<div class="table-responsive">
|
||||
|
||||
@ -266,7 +266,7 @@
|
||||
<label for="{{ form.source_type.id_for_label }}" class="form-label">
|
||||
{{ form.source_type.label }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.source_type|add_class:"form-select" }}
|
||||
{{ form.source_type|add_class:"form-control" }}
|
||||
{% if form.source_type.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.source_type.errors %}
|
||||
|
||||
@ -9,9 +9,9 @@
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'settings' %}" class="text-decoration-none text-secondary">{% trans "Settings" %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page" style="
|
||||
color: #F43B5E; /* Rosy Accent Color */
|
||||
font-weight: 600;
|
||||
">{% trans "Sources Settings" %}</li>
|
||||
color: #F43B5E; /* Rosy Accent Color */
|
||||
font-weight: 600;
|
||||
">{% trans "Sources Settings" %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="row">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user