This commit is contained in:
Iliyan Angelov
2025-10-07 22:10:27 +03:00
parent 3f5bcfad68
commit d48c54e2c5
3221 changed files with 40187 additions and 92575 deletions

View File

View File

@@ -0,0 +1,149 @@
from django.contrib import admin
from django.utils.html import format_html
from .models import JobPosition, JobApplication
@admin.register(JobPosition)
class JobPositionAdmin(admin.ModelAdmin):
list_display = [
'title',
'department',
'location',
'open_positions',
'employment_type',
'status_badge',
'featured',
'posted_date',
'applications_count'
]
list_filter = ['status', 'employment_type', 'location_type', 'featured', 'department', 'posted_date']
search_fields = ['title', 'department', 'location', 'short_description']
prepopulated_fields = {'slug': ('title',)}
readonly_fields = ['posted_date', 'updated_date', 'applications_count']
fieldsets = (
('Basic Information', {
'fields': ('title', 'slug', 'department', 'status', 'featured', 'priority')
}),
('Employment Details', {
'fields': ('employment_type', 'location_type', 'location', 'open_positions', 'experience_required')
}),
('Salary Information', {
'fields': ('salary_min', 'salary_max', 'salary_currency', 'salary_period', 'salary_additional')
}),
('Job Description', {
'fields': ('short_description', 'about_role')
}),
('Requirements & Qualifications', {
'fields': ('requirements', 'responsibilities', 'qualifications', 'bonus_points', 'benefits'),
'classes': ('collapse',)
}),
('Dates', {
'fields': ('start_date', 'deadline', 'posted_date', 'updated_date')
}),
)
def status_badge(self, obj):
colors = {
'active': 'green',
'closed': 'red',
'draft': 'orange'
}
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
colors.get(obj.status, 'black'),
obj.get_status_display()
)
status_badge.short_description = 'Status'
def applications_count(self, obj):
count = obj.applications.count()
return format_html(
'<span style="font-weight: bold;">{} application(s)</span>',
count
)
applications_count.short_description = 'Applications'
@admin.register(JobApplication)
class JobApplicationAdmin(admin.ModelAdmin):
list_display = [
'full_name',
'email',
'job',
'status_badge',
'applied_date',
'resume_link'
]
list_filter = ['status', 'job', 'applied_date']
search_fields = ['first_name', 'last_name', 'email', 'job__title']
readonly_fields = ['applied_date', 'updated_date', 'resume_link']
fieldsets = (
('Job Information', {
'fields': ('job', 'status')
}),
('Applicant Information', {
'fields': ('first_name', 'last_name', 'email', 'phone')
}),
('Professional Information', {
'fields': ('current_position', 'current_company', 'years_of_experience')
}),
('Application Details', {
'fields': ('cover_letter', 'resume', 'resume_link', 'portfolio_url')
}),
('Social Links', {
'fields': ('linkedin_url', 'github_url', 'website_url'),
'classes': ('collapse',)
}),
('Availability & Salary', {
'fields': ('available_from', 'notice_period', 'expected_salary', 'salary_currency'),
'classes': ('collapse',)
}),
('Metadata', {
'fields': ('applied_date', 'updated_date', 'consent', 'notes')
}),
)
def status_badge(self, obj):
colors = {
'new': 'blue',
'reviewing': 'orange',
'shortlisted': 'purple',
'interviewed': 'teal',
'accepted': 'green',
'rejected': 'red'
}
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
colors.get(obj.status, 'black'),
obj.get_status_display()
)
status_badge.short_description = 'Status'
def resume_link(self, obj):
if obj.resume:
return format_html(
'<a href="{}" target="_blank">Download Resume</a>',
obj.resume.url
)
return '-'
resume_link.short_description = 'Resume'
actions = ['mark_as_reviewing', 'mark_as_shortlisted', 'mark_as_rejected']
def mark_as_reviewing(self, request, queryset):
queryset.update(status='reviewing')
self.message_user(request, f'{queryset.count()} application(s) marked as reviewing.')
mark_as_reviewing.short_description = 'Mark as Reviewing'
def mark_as_shortlisted(self, request, queryset):
queryset.update(status='shortlisted')
self.message_user(request, f'{queryset.count()} application(s) marked as shortlisted.')
mark_as_shortlisted.short_description = 'Mark as Shortlisted'
def mark_as_rejected(self, request, queryset):
queryset.update(status='rejected')
self.message_user(request, f'{queryset.count()} application(s) marked as rejected.')
mark_as_rejected.short_description = 'Mark as Rejected'

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class CareerConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'career'

View File

@@ -0,0 +1,110 @@
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
class CareerEmailService:
"""Service for handling career-related emails"""
def send_application_confirmation(self, application):
"""
Send confirmation email to applicant
"""
try:
subject = f"Application Received - {application.job.title}"
from_email = settings.DEFAULT_FROM_EMAIL
to_email = [application.email]
# Create context for email template
context = {
'applicant_name': application.full_name,
'job_title': application.job.title,
'job_location': application.job.location,
'application_date': application.applied_date,
}
# Render email templates
text_content = render_to_string('career/application_confirmation.txt', context)
html_content = render_to_string('career/application_confirmation.html', context)
# Create email
email = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=from_email,
to=to_email
)
email.attach_alternative(html_content, "text/html")
# Send email
email.send(fail_silently=False)
logger.info(f"Confirmation email sent to {application.email}")
return True
except Exception as e:
logger.error(f"Failed to send confirmation email: {str(e)}", exc_info=True)
return False
def send_application_notification_to_admin(self, application):
"""
Send notification email to company about new application
"""
try:
subject = f"New Job Application: {application.job.title} - {application.full_name}"
from_email = settings.DEFAULT_FROM_EMAIL
to_email = [settings.COMPANY_EMAIL]
# Create context for email template
context = {
'applicant_name': application.full_name,
'applicant_email': application.email,
'applicant_phone': application.phone,
'job_title': application.job.title,
'current_position': application.current_position,
'current_company': application.current_company,
'years_of_experience': application.years_of_experience,
'cover_letter': application.cover_letter,
'portfolio_url': application.portfolio_url,
'linkedin_url': application.linkedin_url,
'github_url': application.github_url,
'website_url': application.website_url,
'expected_salary': application.expected_salary,
'salary_currency': application.salary_currency,
'available_from': application.available_from,
'notice_period': application.notice_period,
'application_date': application.applied_date,
'resume_url': application.resume.url if application.resume else None,
}
# Render email templates
text_content = render_to_string('career/application_notification.txt', context)
html_content = render_to_string('career/application_notification.html', context)
# Create email
email = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=from_email,
to=to_email,
reply_to=[application.email]
)
email.attach_alternative(html_content, "text/html")
# Attach resume if available
if application.resume:
email.attach_file(application.resume.path)
# Send email
email.send(fail_silently=False)
logger.info(f"Admin notification email sent for application from {application.email}")
return True
except Exception as e:
logger.error(f"Failed to send admin notification email: {str(e)}", exc_info=True)
return False

View File

@@ -0,0 +1,305 @@
from django.core.management.base import BaseCommand
from career.models import JobPosition
class Command(BaseCommand):
help = 'Populate database with sample job positions'
def handle(self, *args, **kwargs):
self.stdout.write('Creating sample job positions...')
jobs_data = [
{
'title': 'UI/UX Designer',
'department': 'Design',
'employment_type': 'full-time',
'location_type': 'remote',
'location': 'Remote',
'open_positions': 2,
'experience_required': '3+ years',
'salary_min': 1500,
'salary_max': 2500,
'salary_currency': 'USD',
'salary_period': 'per month',
'salary_additional': '+ VAT (B2B) + bonuses',
'short_description': 'Join our design team to create beautiful and intuitive user experiences.',
'about_role': 'We are looking for a talented UI/UX Designer who is passionate about creating exceptional user experiences. You will work closely with our product and engineering teams to design and implement user-friendly interfaces for our web and mobile applications.',
'requirements': [
'At least 3 years of commercial UI/UX design experience',
'Strong portfolio showcasing web/UI projects',
'Fluent English in verbal and written communication',
'Proficiency in design tools (Sketch, Figma, or Adobe XD)',
'Understanding of responsive design principles',
'Experience with user research and usability testing',
],
'responsibilities': [
'Create wireframes, prototypes, and high-fidelity designs',
'Conduct user research and usability testing',
'Collaborate with developers to implement designs',
'Maintain and evolve design systems',
'Present design concepts to stakeholders',
],
'qualifications': [
'Portfolio demonstrating strong UI/UX design skills',
'Experience with design systems',
'Knowledge of HTML/CSS basics',
'Understanding of accessibility standards',
],
'bonus_points': [
'Experience with motion design',
'Knowledge of front-end development',
'Illustration skills',
'Experience with design tokens',
],
'benefits': [
'Remote work flexibility',
'Competitive salary package',
'Professional development budget',
'Latest design tools and software',
'Health insurance',
'Flexible working hours',
],
'start_date': 'ASAP',
'status': 'active',
'featured': True,
'priority': 10,
},
{
'title': 'Senior Full Stack Developer',
'department': 'Engineering',
'employment_type': 'full-time',
'location_type': 'hybrid',
'location': 'Hybrid / Remote',
'open_positions': 3,
'experience_required': '5+ years',
'salary_min': 3000,
'salary_max': 5000,
'salary_currency': 'USD',
'salary_period': 'per month',
'salary_additional': '+ Annual bonus + Stock options',
'short_description': 'Build scalable applications with cutting-edge technologies.',
'about_role': 'We are seeking an experienced Full Stack Developer to join our engineering team. You will be responsible for developing and maintaining our web applications using modern technologies and best practices.',
'requirements': [
'5+ years of full-stack development experience',
'Strong proficiency in React, Next.js, and TypeScript',
'Experience with Python/Django or Node.js',
'Solid understanding of RESTful APIs and GraphQL',
'Experience with SQL and NoSQL databases',
'Familiarity with cloud platforms (AWS, GCP, or Azure)',
],
'responsibilities': [
'Develop and maintain web applications',
'Write clean, maintainable, and efficient code',
'Participate in code reviews',
'Collaborate with cross-functional teams',
'Optimize applications for performance',
'Mentor junior developers',
],
'qualifications': [
'Bachelor\'s degree in Computer Science or related field',
'Strong problem-solving skills',
'Experience with version control (Git)',
'Good understanding of software development lifecycle',
],
'bonus_points': [
'Experience with Docker and Kubernetes',
'Knowledge of CI/CD pipelines',
'Contributions to open-source projects',
'Experience with microservices architecture',
],
'benefits': [
'Competitive salary with stock options',
'Flexible work arrangements',
'Latest MacBook Pro or custom PC',
'Learning and development budget',
'Health and dental insurance',
'Gym membership',
],
'start_date': 'Within 1 month',
'status': 'active',
'featured': True,
'priority': 9,
},
{
'title': 'Digital Marketing Manager',
'department': 'Marketing',
'employment_type': 'full-time',
'location_type': 'on-site',
'location': 'New York, NY',
'open_positions': 1,
'experience_required': '4+ years',
'salary_min': 2500,
'salary_max': 4000,
'salary_currency': 'USD',
'salary_period': 'per month',
'short_description': 'Lead our digital marketing efforts and grow our online presence.',
'about_role': 'We are looking for a creative and data-driven Digital Marketing Manager to lead our marketing initiatives. You will be responsible for developing and executing marketing strategies to increase brand awareness and drive customer acquisition.',
'requirements': [
'4+ years of digital marketing experience',
'Proven track record of successful marketing campaigns',
'Strong understanding of SEO, SEM, and social media marketing',
'Experience with marketing automation tools',
'Excellent analytical and communication skills',
'Data-driven mindset with strong analytical abilities',
],
'responsibilities': [
'Develop and execute digital marketing strategies',
'Manage social media channels and campaigns',
'Oversee content marketing initiatives',
'Analyze campaign performance and ROI',
'Manage marketing budget',
'Collaborate with sales and product teams',
],
'qualifications': [
'Bachelor\'s degree in Marketing or related field',
'Experience with Google Analytics and marketing tools',
'Strong project management skills',
'Creative thinking and problem-solving abilities',
],
'bonus_points': [
'Experience with B2B marketing',
'Knowledge of marketing automation platforms',
'Video production skills',
'Experience with influencer marketing',
],
'benefits': [
'Competitive salary package',
'Marketing conferences and events budget',
'Professional development opportunities',
'Health insurance',
'Paid time off',
],
'start_date': 'ASAP',
'status': 'active',
'featured': False,
'priority': 7,
},
{
'title': 'Data Analyst',
'department': 'Analytics',
'employment_type': 'full-time',
'location_type': 'remote',
'location': 'Remote',
'open_positions': 2,
'experience_required': '2+ years',
'salary_min': 2000,
'salary_max': 3500,
'salary_currency': 'USD',
'salary_period': 'per month',
'short_description': 'Turn data into actionable insights to drive business decisions.',
'about_role': 'We are seeking a Data Analyst to join our analytics team. You will be responsible for analyzing complex datasets, creating reports, and providing insights to support business decision-making.',
'requirements': [
'2+ years of data analysis experience',
'Proficiency in SQL and data visualization tools',
'Experience with Python or R for data analysis',
'Strong statistical and analytical skills',
'Ability to communicate insights to non-technical stakeholders',
],
'responsibilities': [
'Analyze large datasets to identify trends and patterns',
'Create dashboards and reports',
'Collaborate with stakeholders to understand data needs',
'Perform statistical analysis',
'Present findings to leadership team',
],
'qualifications': [
'Bachelor\'s degree in Statistics, Mathematics, or related field',
'Experience with Tableau, Power BI, or similar tools',
'Strong attention to detail',
'Excellent problem-solving skills',
],
'bonus_points': [
'Experience with machine learning',
'Knowledge of big data technologies',
'Experience with A/B testing',
],
'benefits': [
'Remote work',
'Competitive compensation',
'Learning and development budget',
'Health insurance',
'Flexible hours',
],
'start_date': 'Within 2 weeks',
'status': 'active',
'featured': False,
'priority': 6,
},
{
'title': 'Product Manager',
'department': 'Product',
'employment_type': 'full-time',
'location_type': 'hybrid',
'location': 'San Francisco, CA / Remote',
'open_positions': 1,
'experience_required': '5+ years',
'salary_min': 4000,
'salary_max': 6000,
'salary_currency': 'USD',
'salary_period': 'per month',
'salary_additional': '+ Stock options + Annual bonus',
'short_description': 'Lead product strategy and development for our flagship products.',
'about_role': 'We are looking for an experienced Product Manager to drive the vision and execution of our products. You will work closely with engineering, design, and marketing teams to deliver exceptional products that delight our customers.',
'requirements': [
'5+ years of product management experience',
'Proven track record of successful product launches',
'Strong understanding of agile methodologies',
'Excellent communication and leadership skills',
'Data-driven decision-making approach',
'Experience with product analytics tools',
],
'responsibilities': [
'Define product vision and strategy',
'Create and maintain product roadmap',
'Gather and prioritize requirements',
'Work with engineering team on implementation',
'Conduct market research and competitive analysis',
'Analyze product metrics and user feedback',
],
'qualifications': [
'Bachelor\'s degree (MBA preferred)',
'Strong analytical and problem-solving skills',
'Experience with product management tools',
'Understanding of UX principles',
],
'bonus_points': [
'Technical background',
'Experience in SaaS products',
'Knowledge of growth strategies',
],
'benefits': [
'Competitive salary with equity',
'Hybrid work model',
'Professional development budget',
'Health and wellness benefits',
'Team offsites and events',
],
'start_date': 'Within 1 month',
'status': 'active',
'featured': True,
'priority': 8,
},
]
created_count = 0
for job_data in jobs_data:
job, created = JobPosition.objects.get_or_create(
slug=job_data['title'].lower().replace(' ', '-').replace('/', '-'),
defaults=job_data
)
if created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(f'✓ Created job: {job.title}')
)
else:
self.stdout.write(
self.style.WARNING(f'- Job already exists: {job.title}')
)
self.stdout.write(
self.style.SUCCESS(
f'\nSuccessfully created {created_count} job position(s)!'
)
)

View File

@@ -0,0 +1,88 @@
# Generated by Django 4.2.7 on 2025-10-07 14:45
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='JobPosition',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(help_text='Job title', max_length=255)),
('slug', models.SlugField(blank=True, max_length=255, unique=True)),
('department', models.CharField(blank=True, help_text='Department or category', max_length=100)),
('employment_type', models.CharField(choices=[('full-time', 'Full Time'), ('part-time', 'Part Time'), ('contract', 'Contract'), ('internship', 'Internship'), ('remote', 'Remote')], default='full-time', max_length=20)),
('location_type', models.CharField(choices=[('remote', 'Remote'), ('on-site', 'On-site'), ('hybrid', 'Hybrid')], default='remote', max_length=20)),
('location', models.CharField(default='Remote', help_text='Work location', max_length=255)),
('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions')),
('experience_required', models.CharField(blank=True, help_text='e.g., 3+ years', max_length=100)),
('salary_min', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('salary_max', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('salary_currency', models.CharField(default='USD', max_length=10)),
('salary_period', models.CharField(default='per month', help_text='e.g., per month, per year', max_length=20)),
('salary_additional', models.TextField(blank=True, help_text='Additional salary info like bonuses, benefits')),
('short_description', models.TextField(blank=True, help_text='Brief description for listing page')),
('about_role', models.TextField(blank=True, help_text='About this role / Who we are')),
('requirements', models.JSONField(blank=True, default=list, help_text='List of requirements')),
('responsibilities', models.JSONField(blank=True, default=list, help_text='List of responsibilities')),
('qualifications', models.JSONField(blank=True, default=list, help_text='List of qualifications')),
('bonus_points', models.JSONField(blank=True, default=list, help_text='Nice to have skills')),
('benefits', models.JSONField(blank=True, default=list, help_text='What you get')),
('start_date', models.CharField(default='ASAP', help_text='Expected start date', max_length=100)),
('posted_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('deadline', models.DateTimeField(blank=True, help_text='Application deadline', null=True)),
('status', models.CharField(choices=[('active', 'Active'), ('closed', 'Closed'), ('draft', 'Draft')], default='active', max_length=20)),
('featured', models.BooleanField(default=False, help_text='Feature this job on homepage')),
('priority', models.IntegerField(default=0, help_text='Higher number = higher priority in listing')),
],
options={
'verbose_name': 'Job Position',
'verbose_name_plural': 'Job Positions',
'ordering': ['-priority', '-posted_date'],
},
),
migrations.CreateModel(
name='JobApplication',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('first_name', models.CharField(max_length=100)),
('last_name', models.CharField(max_length=100)),
('email', models.EmailField(max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('current_position', models.CharField(blank=True, help_text='Current job title', max_length=255)),
('current_company', models.CharField(blank=True, max_length=255)),
('years_of_experience', models.CharField(blank=True, max_length=50)),
('cover_letter', models.TextField(blank=True, help_text='Cover letter or message')),
('resume', models.FileField(help_text='Upload your resume (PDF, DOC, DOCX)', upload_to='career/resumes/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx'])])),
('portfolio_url', models.URLField(blank=True, help_text='Link to portfolio or LinkedIn')),
('linkedin_url', models.URLField(blank=True)),
('github_url', models.URLField(blank=True)),
('website_url', models.URLField(blank=True)),
('available_from', models.DateField(blank=True, help_text='When can you start?', null=True)),
('notice_period', models.CharField(blank=True, help_text='Notice period if applicable', max_length=100)),
('expected_salary', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('salary_currency', models.CharField(default='USD', max_length=10)),
('status', models.CharField(choices=[('new', 'New'), ('reviewing', 'Reviewing'), ('shortlisted', 'Shortlisted'), ('interviewed', 'Interviewed'), ('accepted', 'Accepted'), ('rejected', 'Rejected')], default='new', max_length=20)),
('applied_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('notes', models.TextField(blank=True, help_text='Internal notes (not visible to applicant)')),
('consent', models.BooleanField(default=False, help_text='Consent to data processing')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='career.jobposition')),
],
options={
'verbose_name': 'Job Application',
'verbose_name_plural': 'Job Applications',
'ordering': ['-applied_date'],
},
),
]

View File

@@ -0,0 +1,166 @@
from django.db import models
from django.utils.text import slugify
from django.core.validators import FileExtensionValidator
class JobPosition(models.Model):
"""Model for job positions/openings"""
EMPLOYMENT_TYPE_CHOICES = [
('full-time', 'Full Time'),
('part-time', 'Part Time'),
('contract', 'Contract'),
('internship', 'Internship'),
('remote', 'Remote'),
]
LOCATION_TYPE_CHOICES = [
('remote', 'Remote'),
('on-site', 'On-site'),
('hybrid', 'Hybrid'),
]
STATUS_CHOICES = [
('active', 'Active'),
('closed', 'Closed'),
('draft', 'Draft'),
]
# Basic Information
title = models.CharField(max_length=255, help_text="Job title")
slug = models.SlugField(max_length=255, unique=True, blank=True)
department = models.CharField(max_length=100, blank=True, help_text="Department or category")
# Employment Details
employment_type = models.CharField(
max_length=20,
choices=EMPLOYMENT_TYPE_CHOICES,
default='full-time'
)
location_type = models.CharField(
max_length=20,
choices=LOCATION_TYPE_CHOICES,
default='remote'
)
location = models.CharField(max_length=255, default='Remote', help_text="Work location")
# Position Details
open_positions = models.PositiveIntegerField(default=1, help_text="Number of open positions")
experience_required = models.CharField(max_length=100, blank=True, help_text="e.g., 3+ years")
# Salary Information
salary_min = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
salary_max = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
salary_currency = models.CharField(max_length=10, default='USD')
salary_period = models.CharField(max_length=20, default='per month', help_text="e.g., per month, per year")
salary_additional = models.TextField(blank=True, help_text="Additional salary info like bonuses, benefits")
# Job Description
short_description = models.TextField(blank=True, help_text="Brief description for listing page")
about_role = models.TextField(blank=True, help_text="About this role / Who we are")
requirements = models.JSONField(default=list, blank=True, help_text="List of requirements")
responsibilities = models.JSONField(default=list, blank=True, help_text="List of responsibilities")
qualifications = models.JSONField(default=list, blank=True, help_text="List of qualifications")
bonus_points = models.JSONField(default=list, blank=True, help_text="Nice to have skills")
benefits = models.JSONField(default=list, blank=True, help_text="What you get")
# Dates and Status
start_date = models.CharField(max_length=100, default='ASAP', help_text="Expected start date")
posted_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
deadline = models.DateTimeField(null=True, blank=True, help_text="Application deadline")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
# SEO and Metadata
featured = models.BooleanField(default=False, help_text="Feature this job on homepage")
priority = models.IntegerField(default=0, help_text="Higher number = higher priority in listing")
class Meta:
ordering = ['-priority', '-posted_date']
verbose_name = 'Job Position'
verbose_name_plural = 'Job Positions'
def __str__(self):
return f"{self.title} ({self.open_positions} positions)"
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
# Ensure unique slug
original_slug = self.slug
counter = 1
while JobPosition.objects.filter(slug=self.slug).exists():
self.slug = f"{original_slug}-{counter}"
counter += 1
super().save(*args, **kwargs)
class JobApplication(models.Model):
"""Model for job applications"""
STATUS_CHOICES = [
('new', 'New'),
('reviewing', 'Reviewing'),
('shortlisted', 'Shortlisted'),
('interviewed', 'Interviewed'),
('accepted', 'Accepted'),
('rejected', 'Rejected'),
]
# Related Job
job = models.ForeignKey(JobPosition, on_delete=models.CASCADE, related_name='applications')
# Applicant Information
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
email = models.EmailField()
phone = models.CharField(max_length=20, blank=True)
# Professional Information
current_position = models.CharField(max_length=255, blank=True, help_text="Current job title")
current_company = models.CharField(max_length=255, blank=True)
years_of_experience = models.CharField(max_length=50, blank=True)
# Application Details
cover_letter = models.TextField(blank=True, help_text="Cover letter or message")
resume = models.FileField(
upload_to='career/resumes/%Y/%m/',
validators=[FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx'])],
help_text="Upload your resume (PDF, DOC, DOCX)"
)
portfolio_url = models.URLField(blank=True, help_text="Link to portfolio or LinkedIn")
# Additional Information
linkedin_url = models.URLField(blank=True)
github_url = models.URLField(blank=True)
website_url = models.URLField(blank=True)
# Availability
available_from = models.DateField(null=True, blank=True, help_text="When can you start?")
notice_period = models.CharField(max_length=100, blank=True, help_text="Notice period if applicable")
# Salary Expectations
expected_salary = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
salary_currency = models.CharField(max_length=10, default='USD')
# Application Metadata
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='new')
applied_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
notes = models.TextField(blank=True, help_text="Internal notes (not visible to applicant)")
# Privacy
consent = models.BooleanField(default=False, help_text="Consent to data processing")
class Meta:
ordering = ['-applied_date']
verbose_name = 'Job Application'
verbose_name_plural = 'Job Applications'
def __str__(self):
return f"{self.first_name} {self.last_name} - {self.job.title}"
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"

View File

@@ -0,0 +1,131 @@
from rest_framework import serializers
from .models import JobPosition, JobApplication
class JobPositionListSerializer(serializers.ModelSerializer):
"""Serializer for job position list view"""
class Meta:
model = JobPosition
fields = [
'id',
'title',
'slug',
'department',
'employment_type',
'location_type',
'location',
'open_positions',
'short_description',
'salary_min',
'salary_max',
'salary_currency',
'salary_period',
'posted_date',
'status',
'featured',
]
class JobPositionDetailSerializer(serializers.ModelSerializer):
"""Serializer for job position detail view"""
class Meta:
model = JobPosition
fields = [
'id',
'title',
'slug',
'department',
'employment_type',
'location_type',
'location',
'open_positions',
'experience_required',
'salary_min',
'salary_max',
'salary_currency',
'salary_period',
'salary_additional',
'short_description',
'about_role',
'requirements',
'responsibilities',
'qualifications',
'bonus_points',
'benefits',
'start_date',
'posted_date',
'updated_date',
'deadline',
'status',
]
class JobApplicationSerializer(serializers.ModelSerializer):
"""Serializer for job application submission"""
job_title = serializers.CharField(source='job.title', read_only=True)
class Meta:
model = JobApplication
fields = [
'id',
'job',
'job_title',
'first_name',
'last_name',
'email',
'phone',
'current_position',
'current_company',
'years_of_experience',
'cover_letter',
'resume',
'portfolio_url',
'linkedin_url',
'github_url',
'website_url',
'available_from',
'notice_period',
'expected_salary',
'salary_currency',
'consent',
'applied_date',
]
read_only_fields = ['id', 'applied_date', 'job_title']
def validate_resume(self, value):
"""Validate resume file size"""
if value.size > 5 * 1024 * 1024: # 5MB
raise serializers.ValidationError("Resume file size cannot exceed 5MB")
return value
def validate_consent(self, value):
"""Ensure user has given consent"""
if not value:
raise serializers.ValidationError("You must consent to data processing to apply")
return value
class JobApplicationListSerializer(serializers.ModelSerializer):
"""Serializer for listing job applications (admin view)"""
job_title = serializers.CharField(source='job.title', read_only=True)
full_name = serializers.CharField(read_only=True)
class Meta:
model = JobApplication
fields = [
'id',
'job',
'job_title',
'full_name',
'email',
'phone',
'status',
'applied_date',
'resume',
]
read_only_fields = fields

View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #4A90E2;
color: white;
padding: 20px;
text-align: center;
}
.content {
background-color: #f9f9f9;
padding: 30px;
margin-top: 20px;
}
.details {
background-color: white;
padding: 15px;
margin: 20px 0;
border-left: 4px solid #4A90E2;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ddd;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Application Received</h1>
</div>
<div class="content">
<p>Dear <strong>{{ applicant_name }}</strong>,</p>
<p>Thank you for applying for the <strong>{{ job_title }}</strong> position at GNX!</p>
<p>We have received your application and our team will review it carefully. We appreciate your interest in joining our team.</p>
<div class="details">
<h3>Application Details</h3>
<ul>
<li><strong>Position:</strong> {{ job_title }}</li>
<li><strong>Location:</strong> {{ job_location }}</li>
<li><strong>Applied on:</strong> {{ application_date|date:"F d, Y" }}</li>
</ul>
</div>
<h3>What happens next?</h3>
<p>Our hiring team will review your application and resume. If your qualifications match our requirements, we will contact you within 1-2 weeks to discuss the next steps.</p>
<p>If you have any questions, please don't hesitate to reach out to us.</p>
<p>Best regards,<br><strong>The GNX Team</strong></p>
</div>
<div class="footer">
<p>This is an automated message. Please do not reply to this email.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,22 @@
Dear {{ applicant_name }},
Thank you for applying for the {{ job_title }} position at GNX!
We have received your application and our team will review it carefully. We appreciate your interest in joining our team.
Application Details:
- Position: {{ job_title }}
- Location: {{ job_location }}
- Applied on: {{ application_date|date:"F d, Y" }}
What happens next?
Our hiring team will review your application and resume. If your qualifications match our requirements, we will contact you within 1-2 weeks to discuss the next steps.
If you have any questions, please don't hesitate to reach out to us.
Best regards,
The GNX Team
---
This is an automated message. Please do not reply to this email.

View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #2C3E50;
color: white;
padding: 20px;
text-align: center;
}
.content {
background-color: #f9f9f9;
padding: 30px;
margin-top: 20px;
}
.section {
background-color: white;
padding: 20px;
margin: 15px 0;
border-left: 4px solid #3498db;
}
.section h3 {
margin-top: 0;
color: #2C3E50;
}
.info-row {
margin: 10px 0;
}
.label {
font-weight: bold;
color: #555;
}
.cover-letter {
background-color: #f0f0f0;
padding: 15px;
border-radius: 5px;
white-space: pre-wrap;
margin-top: 10px;
}
.links a {
display: inline-block;
margin: 5px 10px 5px 0;
color: #3498db;
text-decoration: none;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ddd;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎯 New Job Application</h1>
<p>{{ job_title }}</p>
</div>
<div class="content">
<div class="section">
<h3>👤 Applicant Information</h3>
<div class="info-row">
<span class="label">Name:</span> {{ applicant_name }}
</div>
<div class="info-row">
<span class="label">Email:</span> <a href="mailto:{{ applicant_email }}">{{ applicant_email }}</a>
</div>
{% if applicant_phone %}
<div class="info-row">
<span class="label">Phone:</span> {{ applicant_phone }}
</div>
{% endif %}
</div>
<div class="section">
<h3>💼 Professional Information</h3>
{% if current_position %}
<div class="info-row">
<span class="label">Current Position:</span> {{ current_position }}
</div>
{% endif %}
{% if current_company %}
<div class="info-row">
<span class="label">Current Company:</span> {{ current_company }}
</div>
{% endif %}
{% if years_of_experience %}
<div class="info-row">
<span class="label">Years of Experience:</span> {{ years_of_experience }}
</div>
{% endif %}
</div>
<div class="section">
<h3>📋 Application Details</h3>
<div class="info-row">
<span class="label">Position Applied:</span> {{ job_title }}
</div>
<div class="info-row">
<span class="label">Application Date:</span> {{ application_date|date:"F d, Y at h:i A" }}
</div>
{% if expected_salary %}
<div class="info-row">
<span class="label">Expected Salary:</span> {{ expected_salary }} {{ salary_currency }}
</div>
{% endif %}
{% if available_from %}
<div class="info-row">
<span class="label">Available From:</span> {{ available_from|date:"F d, Y" }}
</div>
{% endif %}
{% if notice_period %}
<div class="info-row">
<span class="label">Notice Period:</span> {{ notice_period }}
</div>
{% endif %}
</div>
{% if portfolio_url or linkedin_url or github_url or website_url %}
<div class="section">
<h3>🔗 Links</h3>
<div class="links">
{% if portfolio_url %}<a href="{{ portfolio_url }}" target="_blank">📁 Portfolio</a>{% endif %}
{% if linkedin_url %}<a href="{{ linkedin_url }}" target="_blank">💼 LinkedIn</a>{% endif %}
{% if github_url %}<a href="{{ github_url }}" target="_blank">💻 GitHub</a>{% endif %}
{% if website_url %}<a href="{{ website_url }}" target="_blank">🌐 Website</a>{% endif %}
</div>
</div>
{% endif %}
{% if cover_letter %}
<div class="section">
<h3>✉️ Cover Letter</h3>
<div class="cover-letter">{{ cover_letter }}</div>
</div>
{% endif %}
</div>
<div class="footer">
<p><strong>Resume is attached to this email.</strong></p>
<p>Please log in to the admin panel to review the full application and update its status.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,48 @@
New Job Application Received
A new application has been submitted for the {{ job_title }} position.
Applicant Information:
--------------------
Name: {{ applicant_name }}
Email: {{ applicant_email }}
Phone: {{ applicant_phone }}
Professional Information:
-----------------------
Current Position: {{ current_position }}
Current Company: {{ current_company }}
Years of Experience: {{ years_of_experience }}
Application Details:
------------------
Applied for: {{ job_title }}
Application Date: {{ application_date|date:"F d, Y at h:i A" }}
{% if expected_salary %}
Expected Salary: {{ expected_salary }} {{ salary_currency }}
{% endif %}
{% if available_from %}
Available From: {{ available_from|date:"F d, Y" }}
{% endif %}
{% if notice_period %}
Notice Period: {{ notice_period }}
{% endif %}
Links:
------
{% if portfolio_url %}Portfolio: {{ portfolio_url }}{% endif %}
{% if linkedin_url %}LinkedIn: {{ linkedin_url }}{% endif %}
{% if github_url %}GitHub: {{ github_url }}{% endif %}
{% if website_url %}Website: {{ website_url }}{% endif %}
Cover Letter:
------------
{{ cover_letter }}
---
Resume is attached to this email.
Please log in to the admin panel to review the full application.

View File

@@ -0,0 +1,4 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import JobPositionViewSet, JobApplicationViewSet
router = DefaultRouter()
router.register(r'jobs', JobPositionViewSet, basename='job')
router.register(r'applications', JobApplicationViewSet, basename='application')
urlpatterns = [
path('', include(router.urls)),
]

View File

@@ -0,0 +1,133 @@
from rest_framework import viewsets, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAdminUser
from django_filters.rest_framework import DjangoFilterBackend
from django.shortcuts import get_object_or_404
import logging
from .models import JobPosition, JobApplication
from .serializers import (
JobPositionListSerializer,
JobPositionDetailSerializer,
JobApplicationSerializer,
JobApplicationListSerializer
)
from .email_service import CareerEmailService
logger = logging.getLogger(__name__)
class JobPositionViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for job positions
GET /api/career/jobs/ - List all active job positions
GET /api/career/jobs/{slug}/ - Get job position by slug
"""
queryset = JobPosition.objects.filter(status='active')
permission_classes = [AllowAny]
lookup_field = 'slug'
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['department', 'employment_type', 'location_type', 'featured']
search_fields = ['title', 'department', 'location', 'short_description']
ordering_fields = ['posted_date', 'priority', 'title']
ordering = ['-priority', '-posted_date']
def get_serializer_class(self):
if self.action == 'retrieve':
return JobPositionDetailSerializer
return JobPositionListSerializer
@action(detail=False, methods=['get'])
def featured(self, request):
"""Get featured job positions"""
featured_jobs = self.queryset.filter(featured=True)
serializer = self.get_serializer(featured_jobs, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get'])
def applications_count(self, request, slug=None):
"""Get number of applications for a job"""
job = self.get_object()
count = job.applications.count()
return Response({'count': count})
class JobApplicationViewSet(viewsets.ModelViewSet):
"""
ViewSet for job applications
POST /api/career/applications/ - Submit a job application
GET /api/career/applications/ - List applications (admin only)
"""
queryset = JobApplication.objects.all()
serializer_class = JobApplicationSerializer
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['job', 'status']
search_fields = ['first_name', 'last_name', 'email', 'job__title']
ordering_fields = ['applied_date', 'status']
ordering = ['-applied_date']
def get_permissions(self):
"""
Allow anyone to create (submit) applications
Only admins can list/view/update/delete applications
"""
if self.action == 'create':
return [AllowAny()]
return [IsAdminUser()]
def get_serializer_class(self):
if self.action == 'list':
return JobApplicationListSerializer
return JobApplicationSerializer
def create(self, request, *args, **kwargs):
"""Submit a job application"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
# Save the application
application = serializer.save()
# Send email notifications
email_service = CareerEmailService()
email_service.send_application_confirmation(application)
email_service.send_application_notification_to_admin(application)
logger.info(f"New job application received: {application.full_name} for {application.job.title}")
return Response(
{
'message': 'Application submitted successfully',
'data': serializer.data
},
status=status.HTTP_201_CREATED
)
except Exception as e:
logger.error(f"Error submitting job application: {str(e)}", exc_info=True)
return Response(
{'error': 'Failed to submit application. Please try again.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=True, methods=['post'])
def update_status(self, request, pk=None):
"""Update application status"""
application = self.get_object()
new_status = request.data.get('status')
if new_status not in dict(JobApplication.STATUS_CHOICES):
return Response(
{'error': 'Invalid status'},
status=status.HTTP_400_BAD_REQUEST
)
application.status = new_status
application.save()
serializer = self.get_serializer(application)
return Response(serializer.data)

Binary file not shown.

View File

@@ -50,6 +50,8 @@ INSTALLED_APPS = [
'contact',
'services',
'about',
'career',
'support',
]
MIDDLEWARE = [
@@ -197,6 +199,12 @@ EMAIL_TIMEOUT = config('EMAIL_TIMEOUT', default=30, cast=int)
# Company email for contact form notifications
COMPANY_EMAIL = config('COMPANY_EMAIL')
# Support email for ticket notifications
SUPPORT_EMAIL = config('SUPPORT_EMAIL', default=config('COMPANY_EMAIL'))
# Site URL for email links
SITE_URL = config('SITE_URL', default='http://localhost:3000')
# Email connection settings for production reliability
EMAIL_CONNECTION_TIMEOUT = config('EMAIL_CONNECTION_TIMEOUT', default=10, cast=int)
EMAIL_READ_TIMEOUT = config('EMAIL_READ_TIMEOUT', default=10, cast=int)

View File

@@ -49,6 +49,8 @@ urlpatterns = [
path('contact/', include('contact.urls')),
path('services/', include('services.urls')),
path('about/', include('about.urls')),
path('career/', include('career.urls')),
path('support/', include('support.urls')),
])),
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
# Support Center Module
Enterprise Support Center for handling customer support tickets, knowledge base articles, and support settings.
## Features
- **Ticket Management**
- Create and track support tickets
- Multiple ticket types (technical, billing, feature requests, etc.)
- Priority and status management
- Ticket categories and tags
- SLA deadline tracking
- Message and activity history
- **Knowledge Base**
- Categorized articles
- Search functionality
- Featured articles
- Article feedback (helpful/not helpful)
- View count tracking
- Rich content support
- **Public API**
- Create tickets without authentication
- Check ticket status by ticket number
- Browse knowledge base articles
- Search articles
## Setup
### 1. Run Migrations
```bash
python manage.py migrate
```
### 2. Populate Initial Data
```bash
python manage.py populate_support_data
```
This will create:
- 5 ticket statuses (Open, In Progress, Pending Response, Resolved, Closed)
- 4 ticket priorities (Low, Medium, High, Critical)
- 6 ticket categories
- 6 knowledge base categories
- 6 sample knowledge base articles
### 3. Admin Access
Access the Django admin panel to manage:
- Support tickets
- Ticket categories, statuses, and priorities
- Knowledge base categories and articles
- Support settings
## API Endpoints
### Tickets
- `GET /api/support/tickets/` - List all tickets
- `POST /api/support/tickets/` - Create a new ticket
- `GET /api/support/tickets/{id}/` - Get ticket details
- `POST /api/support/tickets/check-status/` - Check ticket status by ticket number
- `POST /api/support/tickets/{id}/add-message/` - Add a message to a ticket
### Categories
- `GET /api/support/categories/` - List all ticket categories
- `GET /api/support/statuses/` - List all ticket statuses
- `GET /api/support/priorities/` - List all ticket priorities
### Knowledge Base
- `GET /api/support/knowledge-base/` - List all published articles
- `GET /api/support/knowledge-base/{slug}/` - Get article details
- `GET /api/support/knowledge-base/featured/` - Get featured articles
- `GET /api/support/knowledge-base/by-category/{category_slug}/` - Get articles by category
- `POST /api/support/knowledge-base/{slug}/mark-helpful/` - Mark article as helpful/not helpful
- `GET /api/support/knowledge-base-categories/` - List all KB categories
### Settings
- `GET /api/support/settings/` - List all active support settings
- `GET /api/support/settings/{setting_name}/` - Get specific setting
## Models
### SupportTicket
Main model for support tickets with full tracking capabilities.
### TicketStatus
Ticket status options (Open, In Progress, Resolved, etc.)
### TicketPriority
Priority levels with SLA hours (Low, Medium, High, Critical)
### TicketCategory
Categorize tickets for better organization
### TicketMessage
Messages and updates on tickets
### TicketActivity
Audit trail of all ticket changes
### KnowledgeBaseCategory
Categories for knowledge base articles
### KnowledgeBaseArticle
Knowledge base articles with rich content
### SupportSettings
Configurable support center settings
## Usage Examples
### Create a Ticket
```python
import requests
data = {
"title": "Cannot login to my account",
"description": "I've been trying to login but getting error 500",
"ticket_type": "technical",
"user_name": "John Doe",
"user_email": "john@example.com",
"user_phone": "+1234567890",
"company": "Acme Corp",
"category": 1 # Technical Support category ID
}
response = requests.post('http://localhost:8000/api/support/tickets/', json=data)
ticket = response.json()
print(f"Ticket created: {ticket['ticket_number']}")
```
### Check Ticket Status
```python
import requests
data = {
"ticket_number": "TKT-20231015-ABCDE"
}
response = requests.post('http://localhost:8000/api/support/tickets/check-status/', json=data)
ticket = response.json()
print(f"Status: {ticket['status_name']}")
```
### Search Knowledge Base
```python
import requests
response = requests.get('http://localhost:8000/api/support/knowledge-base/', params={'search': 'login'})
articles = response.json()
for article in articles:
print(f"- {article['title']}")
```
## Frontend Integration
The support center is integrated with the Next.js frontend at `/support-center` with:
- Ticket submission form
- Knowledge base browser with search
- Ticket status checker
- Modern, responsive UI
## Email Notifications
To enable email notifications for tickets, configure email settings in `settings.py` and implement email templates in `support/templates/support/`.
## Security
- All endpoints are public (AllowAny permission)
- Ticket numbers are randomly generated and hard to guess
- Internal notes and messages are hidden from public API
- Rate limiting recommended for production
## Future Enhancements
- [ ] Live chat integration
- [ ] File attachments for tickets
- [ ] Email notifications
- [ ] Ticket assignment and routing
- [ ] SLA breach alerts
- [ ] Advanced analytics dashboard
- [ ] Webhook notifications

View File

View File

@@ -0,0 +1,182 @@
from django.contrib import admin
from .models import (
SupportTicket, TicketStatus, TicketPriority, TicketCategory,
TicketMessage, TicketActivity, KnowledgeBaseCategory,
KnowledgeBaseArticle, SupportSettings, RegisteredEmail
)
@admin.register(TicketStatus)
class TicketStatusAdmin(admin.ModelAdmin):
list_display = ['name', 'color', 'is_closed', 'is_active', 'display_order']
list_filter = ['is_closed', 'is_active']
search_fields = ['name']
ordering = ['display_order', 'name']
@admin.register(TicketPriority)
class TicketPriorityAdmin(admin.ModelAdmin):
list_display = ['name', 'level', 'color', 'sla_hours', 'is_active']
list_filter = ['is_active']
search_fields = ['name']
ordering = ['level']
@admin.register(TicketCategory)
class TicketCategoryAdmin(admin.ModelAdmin):
list_display = ['name', 'icon', 'color', 'is_active', 'display_order']
list_filter = ['is_active']
search_fields = ['name', 'description']
ordering = ['display_order', 'name']
class TicketMessageInline(admin.TabularInline):
model = TicketMessage
extra = 0
fields = ['message_type', 'content', 'author_name', 'created_at', 'is_internal']
readonly_fields = ['created_at']
class TicketActivityInline(admin.TabularInline):
model = TicketActivity
extra = 0
fields = ['activity_type', 'description', 'user_name', 'created_at']
readonly_fields = ['created_at']
@admin.register(SupportTicket)
class SupportTicketAdmin(admin.ModelAdmin):
list_display = [
'ticket_number', 'title', 'user_name', 'user_email',
'status', 'priority', 'category', 'created_at', 'is_escalated'
]
list_filter = ['status', 'priority', 'category', 'ticket_type', 'is_escalated', 'created_at']
search_fields = ['ticket_number', 'title', 'user_name', 'user_email', 'description']
readonly_fields = ['ticket_number', 'created_at', 'updated_at', 'last_activity']
inlines = [TicketMessageInline, TicketActivityInline]
fieldsets = (
('Ticket Information', {
'fields': ('ticket_number', 'title', 'description', 'ticket_type')
}),
('User Information', {
'fields': ('user_name', 'user_email', 'user_phone', 'company')
}),
('Ticket Management', {
'fields': ('category', 'priority', 'status', 'assigned_to', 'assigned_at')
}),
('Escalation', {
'fields': ('is_escalated', 'escalation_reason'),
'classes': ('collapse',)
}),
('Timestamps', {
'fields': ('created_at', 'updated_at', 'closed_at', 'last_activity', 'first_response_at', 'sla_deadline')
}),
('Additional Information', {
'fields': ('tags', 'internal_notes', 'attachments'),
'classes': ('collapse',)
}),
)
ordering = ['-created_at']
@admin.register(TicketMessage)
class TicketMessageAdmin(admin.ModelAdmin):
list_display = ['ticket', 'message_type', 'author_name', 'created_at', 'is_internal', 'is_read']
list_filter = ['message_type', 'is_internal', 'is_read', 'created_at']
search_fields = ['ticket__ticket_number', 'content', 'author_name', 'author_email']
readonly_fields = ['created_at', 'updated_at']
ordering = ['-created_at']
@admin.register(TicketActivity)
class TicketActivityAdmin(admin.ModelAdmin):
list_display = ['ticket', 'activity_type', 'user_name', 'created_at']
list_filter = ['activity_type', 'created_at']
search_fields = ['ticket__ticket_number', 'description', 'user_name']
readonly_fields = ['created_at']
ordering = ['-created_at']
@admin.register(KnowledgeBaseCategory)
class KnowledgeBaseCategoryAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'icon', 'color', 'is_active', 'display_order']
list_filter = ['is_active']
search_fields = ['name', 'description']
prepopulated_fields = {'slug': ('name',)}
ordering = ['display_order', 'name']
@admin.register(KnowledgeBaseArticle)
class KnowledgeBaseArticleAdmin(admin.ModelAdmin):
list_display = [
'title', 'category', 'is_published', 'is_featured',
'view_count', 'helpful_count', 'created_at'
]
list_filter = ['is_published', 'is_featured', 'category', 'created_at']
search_fields = ['title', 'content', 'summary', 'keywords']
prepopulated_fields = {'slug': ('title',)}
readonly_fields = ['view_count', 'helpful_count', 'not_helpful_count', 'created_at', 'updated_at']
fieldsets = (
('Article Information', {
'fields': ('title', 'slug', 'category', 'content', 'summary')
}),
('SEO & Metadata', {
'fields': ('meta_description', 'keywords')
}),
('Publishing', {
'fields': ('author', 'is_published', 'is_featured', 'published_at')
}),
('Statistics', {
'fields': ('view_count', 'helpful_count', 'not_helpful_count'),
'classes': ('collapse',)
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
ordering = ['-created_at']
@admin.register(SupportSettings)
class SupportSettingsAdmin(admin.ModelAdmin):
list_display = ['setting_name', 'is_active', 'created_at', 'updated_at']
list_filter = ['is_active']
search_fields = ['setting_name', 'setting_value', 'description']
ordering = ['setting_name']
@admin.register(RegisteredEmail)
class RegisteredEmailAdmin(admin.ModelAdmin):
list_display = ['email', 'company_name', 'contact_name', 'is_active', 'ticket_count', 'last_ticket_date', 'created_at']
list_filter = ['is_active', 'created_at', 'last_ticket_date']
search_fields = ['email', 'company_name', 'contact_name', 'notes']
readonly_fields = ['added_by', 'created_at', 'updated_at', 'last_ticket_date', 'ticket_count']
ordering = ['-created_at']
fieldsets = (
('Email Information', {
'fields': ('email', 'is_active')
}),
('Contact Details', {
'fields': ('company_name', 'contact_name', 'notes')
}),
('Statistics', {
'fields': ('ticket_count', 'last_ticket_date'),
'classes': ('collapse',)
}),
('System Information', {
'fields': ('added_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
def save_model(self, request, obj, form, change):
"""Automatically set added_by to current user if creating new record"""
if not change: # If creating new object
obj.added_by = request.user
super().save_model(request, obj, form, change)

View File

@@ -0,0 +1,10 @@
from django.apps import AppConfig
class SupportConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'support'
def ready(self):
"""Import signal handlers when app is ready"""
import support.signals

View File

@@ -0,0 +1,161 @@
"""
Email Service for Support Tickets
Handles sending email notifications for ticket creation and updates
"""
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.conf import settings
from django.utils.html import strip_tags
import logging
logger = logging.getLogger(__name__)
class SupportEmailService:
"""Service for sending support ticket related emails"""
@staticmethod
def send_ticket_confirmation_to_user(ticket):
"""
Send ticket confirmation email to the user who created the ticket
Args:
ticket: SupportTicket instance
"""
try:
subject = f'Ticket Created: {ticket.ticket_number}'
# Context for email template
context = {
'ticket': ticket,
'ticket_number': ticket.ticket_number,
'user_name': ticket.user_name,
'title': ticket.title,
'description': ticket.description,
'ticket_type': ticket.get_ticket_type_display(),
'category': ticket.category.name if ticket.category else 'General',
'priority': ticket.priority.name if ticket.priority else 'Medium',
'status': ticket.status.name if ticket.status else 'Open',
'created_at': ticket.created_at.strftime('%B %d, %Y at %I:%M %p'),
'support_url': f'{settings.SITE_URL}/support-center',
}
# Render HTML email
html_message = render_to_string(
'support/ticket_confirmation_user.html',
context
)
# Create plain text version
text_message = render_to_string(
'support/ticket_confirmation_user.txt',
context
)
# Create email
email = EmailMultiAlternatives(
subject=subject,
body=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[ticket.user_email],
)
# Attach HTML version
email.attach_alternative(html_message, "text/html")
# Send email
email.send(fail_silently=False)
logger.info(f'Ticket confirmation email sent to user: {ticket.user_email} for ticket {ticket.ticket_number}')
return True
except Exception as e:
logger.error(f'Failed to send ticket confirmation email to user: {str(e)}')
return False
@staticmethod
def send_ticket_notification_to_company(ticket):
"""
Send ticket notification email to company/support team
Args:
ticket: SupportTicket instance
"""
try:
subject = f'New Support Ticket: {ticket.ticket_number} - {ticket.title}'
# Get company email from settings
company_email = getattr(settings, 'SUPPORT_EMAIL', settings.DEFAULT_FROM_EMAIL)
# Context for email template
context = {
'ticket': ticket,
'ticket_number': ticket.ticket_number,
'user_name': ticket.user_name,
'user_email': ticket.user_email,
'user_phone': ticket.user_phone,
'company': ticket.company,
'title': ticket.title,
'description': ticket.description,
'ticket_type': ticket.get_ticket_type_display(),
'category': ticket.category.name if ticket.category else 'General',
'priority': ticket.priority.name if ticket.priority else 'Medium',
'status': ticket.status.name if ticket.status else 'Open',
'created_at': ticket.created_at.strftime('%B %d, %Y at %I:%M %p'),
'admin_url': f'{settings.SITE_URL}/admin/support/supportticket/{ticket.id}/change/',
}
# Render HTML email
html_message = render_to_string(
'support/ticket_notification_company.html',
context
)
# Create plain text version
text_message = render_to_string(
'support/ticket_notification_company.txt',
context
)
# Create email
email = EmailMultiAlternatives(
subject=subject,
body=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[company_email],
reply_to=[ticket.user_email],
)
# Attach HTML version
email.attach_alternative(html_message, "text/html")
# Send email
email.send(fail_silently=False)
logger.info(f'Ticket notification email sent to company: {company_email} for ticket {ticket.ticket_number}')
return True
except Exception as e:
logger.error(f'Failed to send ticket notification email to company: {str(e)}')
return False
@staticmethod
def send_ticket_created_emails(ticket):
"""
Send both user confirmation and company notification emails
Args:
ticket: SupportTicket instance
Returns:
dict with status of both emails
"""
user_email_sent = SupportEmailService.send_ticket_confirmation_to_user(ticket)
company_email_sent = SupportEmailService.send_ticket_notification_to_company(ticket)
return {
'user_email_sent': user_email_sent,
'company_email_sent': company_email_sent,
}

View File

@@ -0,0 +1,57 @@
"""
Management command to add registered emails for testing
"""
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User
from support.models import RegisteredEmail
class Command(BaseCommand):
help = 'Add sample registered emails for testing'
def handle(self, *args, **kwargs):
# Get the admin user to set as added_by
admin_user = User.objects.filter(is_superuser=True).first()
emails_to_add = [
{
'email': 'admin@gnxsoft.com',
'company_name': 'GNX Software',
'contact_name': 'Admin User',
'notes': 'Primary admin email',
},
{
'email': 'support@gnxsoft.com',
'company_name': 'GNX Software',
'contact_name': 'Support Team',
'notes': 'General support email',
},
]
created_count = 0
for email_data in emails_to_add:
registered_email, created = RegisteredEmail.objects.get_or_create(
email=email_data['email'],
defaults={
'company_name': email_data['company_name'],
'contact_name': email_data['contact_name'],
'notes': email_data['notes'],
'added_by': admin_user,
'is_active': True,
}
)
if created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(f'✓ Created registered email: {email_data["email"]}')
)
else:
self.stdout.write(
self.style.WARNING(f'- Email already exists: {email_data["email"]}')
)
self.stdout.write(
self.style.SUCCESS(f'\n✓ Added {created_count} new registered emails')
)

View File

@@ -0,0 +1,268 @@
from django.core.management.base import BaseCommand
from django.utils.text import slugify
from support.models import (
TicketStatus, TicketPriority, TicketCategory,
KnowledgeBaseCategory, KnowledgeBaseArticle
)
class Command(BaseCommand):
help = 'Populate support center with initial data'
def handle(self, *args, **kwargs):
self.stdout.write(self.style.SUCCESS('Starting to populate support data...'))
# Create Ticket Statuses
self.create_ticket_statuses()
# Create Ticket Priorities
self.create_ticket_priorities()
# Create Ticket Categories
self.create_ticket_categories()
# Create Knowledge Base Categories
self.create_kb_categories()
# Create Knowledge Base Articles
self.create_kb_articles()
self.stdout.write(self.style.SUCCESS('Successfully populated support data!'))
def create_ticket_statuses(self):
self.stdout.write('Creating ticket statuses...')
statuses = [
{'name': 'Open', 'color': '#3b82f6', 'description': 'Ticket has been opened', 'is_closed': False, 'display_order': 1},
{'name': 'In Progress', 'color': '#f59e0b', 'description': 'Ticket is being worked on', 'is_closed': False, 'display_order': 2},
{'name': 'Pending Response', 'color': '#8b5cf6', 'description': 'Waiting for customer response', 'is_closed': False, 'display_order': 3},
{'name': 'Resolved', 'color': '#10b981', 'description': 'Ticket has been resolved', 'is_closed': True, 'display_order': 4},
{'name': 'Closed', 'color': '#6b7280', 'description': 'Ticket has been closed', 'is_closed': True, 'display_order': 5},
]
for status_data in statuses:
status, created = TicketStatus.objects.get_or_create(
name=status_data['name'],
defaults=status_data
)
if created:
self.stdout.write(self.style.SUCCESS(f' ✓ Created status: {status.name}'))
else:
self.stdout.write(f' - Status already exists: {status.name}')
def create_ticket_priorities(self):
self.stdout.write('Creating ticket priorities...')
priorities = [
{'name': 'Low', 'level': 4, 'color': '#6b7280', 'description': 'Low priority issue', 'sla_hours': 72},
{'name': 'Medium', 'level': 3, 'color': '#3b82f6', 'description': 'Medium priority issue', 'sla_hours': 48},
{'name': 'High', 'level': 2, 'color': '#f59e0b', 'description': 'High priority issue', 'sla_hours': 24},
{'name': 'Critical', 'level': 1, 'color': '#ef4444', 'description': 'Critical issue requiring immediate attention', 'sla_hours': 4},
]
for priority_data in priorities:
priority, created = TicketPriority.objects.get_or_create(
name=priority_data['name'],
defaults=priority_data
)
if created:
self.stdout.write(self.style.SUCCESS(f' ✓ Created priority: {priority.name}'))
else:
self.stdout.write(f' - Priority already exists: {priority.name}')
def create_ticket_categories(self):
self.stdout.write('Creating ticket categories...')
categories = [
{'name': 'Technical Support', 'description': 'Technical issues and troubleshooting', 'color': '#3b82f6', 'icon': 'fa-wrench', 'display_order': 1},
{'name': 'Billing & Payments', 'description': 'Billing questions and payment issues', 'color': '#10b981', 'icon': 'fa-credit-card', 'display_order': 2},
{'name': 'Account Management', 'description': 'Account settings and access issues', 'color': '#8b5cf6', 'icon': 'fa-user-cog', 'display_order': 3},
{'name': 'Product Inquiry', 'description': 'Questions about products and features', 'color': '#f59e0b', 'icon': 'fa-box', 'display_order': 4},
{'name': 'Bug Reports', 'description': 'Report software bugs and issues', 'color': '#ef4444', 'icon': 'fa-bug', 'display_order': 5},
{'name': 'Feature Requests', 'description': 'Request new features or improvements', 'color': '#06b6d4', 'icon': 'fa-lightbulb', 'display_order': 6},
]
for category_data in categories:
category, created = TicketCategory.objects.get_or_create(
name=category_data['name'],
defaults=category_data
)
if created:
self.stdout.write(self.style.SUCCESS(f' ✓ Created category: {category.name}'))
else:
self.stdout.write(f' - Category already exists: {category.name}')
def create_kb_categories(self):
self.stdout.write('Creating knowledge base categories...')
categories = [
{'name': 'Getting Started', 'slug': 'getting-started', 'description': 'Learn the basics and get started quickly', 'icon': 'fa-rocket', 'color': '#3b82f6', 'display_order': 1},
{'name': 'Account & Billing', 'slug': 'account-billing', 'description': 'Manage your account and billing information', 'icon': 'fa-user-circle', 'color': '#10b981', 'display_order': 2},
{'name': 'Technical Documentation', 'slug': 'technical-docs', 'description': 'Technical guides and API documentation', 'icon': 'fa-code', 'color': '#8b5cf6', 'display_order': 3},
{'name': 'Troubleshooting', 'slug': 'troubleshooting', 'description': 'Common issues and how to resolve them', 'icon': 'fa-tools', 'color': '#f59e0b', 'display_order': 4},
{'name': 'Security & Privacy', 'slug': 'security-privacy', 'description': 'Security features and privacy settings', 'icon': 'fa-shield-alt', 'color': '#ef4444', 'display_order': 5},
{'name': 'Best Practices', 'slug': 'best-practices', 'description': 'Tips and best practices for optimal use', 'icon': 'fa-star', 'color': '#daa520', 'display_order': 6},
]
for category_data in categories:
category, created = KnowledgeBaseCategory.objects.get_or_create(
slug=category_data['slug'],
defaults=category_data
)
if created:
self.stdout.write(self.style.SUCCESS(f' ✓ Created KB category: {category.name}'))
else:
self.stdout.write(f' - KB category already exists: {category.name}')
def create_kb_articles(self):
self.stdout.write('Creating knowledge base articles...')
# Get categories
getting_started = KnowledgeBaseCategory.objects.filter(slug='getting-started').first()
account_billing = KnowledgeBaseCategory.objects.filter(slug='account-billing').first()
technical = KnowledgeBaseCategory.objects.filter(slug='technical-docs').first()
troubleshooting = KnowledgeBaseCategory.objects.filter(slug='troubleshooting').first()
articles = [
{
'title': 'How to Get Started with Our Platform',
'slug': 'how-to-get-started',
'category': getting_started,
'summary': 'A comprehensive guide to help you get started with our platform quickly and easily.',
'content': '''<h2>Welcome to Our Platform!</h2>
<p>This guide will help you get started with our platform in just a few simple steps.</p>
<h3>Step 1: Create Your Account</h3>
<p>Visit our sign-up page and create your account using your email address or social login.</p>
<h3>Step 2: Complete Your Profile</h3>
<p>Add your company information and customize your profile settings.</p>
<h3>Step 3: Explore the Dashboard</h3>
<p>Familiarize yourself with the main dashboard and available features.</p>
<h3>Step 4: Start Using Our Services</h3>
<p>Begin using our services and tools to achieve your business goals.</p>
<p>If you need any help, our support team is always here to assist you!</p>''',
'is_published': True,
'is_featured': True,
},
{
'title': 'Understanding Your Billing Cycle',
'slug': 'understanding-billing-cycle',
'category': account_billing,
'summary': 'Learn how our billing cycle works and how to manage your payments.',
'content': '''<h2>Billing Cycle Overview</h2>
<p>Understanding your billing cycle is important for managing your subscription effectively.</p>
<h3>Monthly Billing</h3>
<p>For monthly subscriptions, you'll be charged on the same date each month.</p>
<h3>Annual Billing</h3>
<p>Annual subscriptions offer a discount and are billed once per year.</p>
<h3>Managing Your Subscription</h3>
<p>You can upgrade, downgrade, or cancel your subscription at any time from your account settings.</p>''',
'is_published': True,
'is_featured': True,
},
{
'title': 'API Documentation Overview',
'slug': 'api-documentation-overview',
'category': technical,
'summary': 'Complete guide to our API endpoints and authentication.',
'content': '''<h2>API Documentation</h2>
<p>Our API provides programmatic access to all platform features.</p>
<h3>Authentication</h3>
<p>All API requests require authentication using an API key.</p>
<code>Authorization: Bearer YOUR_API_KEY</code>
<h3>Rate Limits</h3>
<p>Standard accounts are limited to 1000 requests per hour.</p>
<h3>Response Format</h3>
<p>All responses are returned in JSON format.</p>''',
'is_published': True,
'is_featured': True,
},
{
'title': 'Common Login Issues and Solutions',
'slug': 'common-login-issues',
'category': troubleshooting,
'summary': 'Troubleshoot common login problems and learn how to resolve them.',
'content': '''<h2>Login Troubleshooting</h2>
<p>Having trouble logging in? Here are some common issues and solutions.</p>
<h3>Forgot Password</h3>
<p>Click "Forgot Password" on the login page to reset your password via email.</p>
<h3>Account Locked</h3>
<p>After multiple failed login attempts, your account may be temporarily locked for security.</p>
<h3>Browser Issues</h3>
<p>Clear your browser cache and cookies, or try a different browser.</p>
<h3>Still Having Issues?</h3>
<p>Contact our support team for personalized assistance.</p>''',
'is_published': True,
'is_featured': False,
},
{
'title': 'How to Update Your Payment Method',
'slug': 'update-payment-method',
'category': account_billing,
'summary': 'Step-by-step guide to updating your payment information.',
'content': '''<h2>Updating Payment Information</h2>
<p>Keep your payment method up to date to avoid service interruptions.</p>
<h3>Steps to Update</h3>
<ol>
<li>Go to Account Settings</li>
<li>Click on "Billing & Payments"</li>
<li>Select "Update Payment Method"</li>
<li>Enter your new payment details</li>
<li>Click "Save Changes"</li>
</ol>
<h3>Supported Payment Methods</h3>
<p>We accept all major credit cards, PayPal, and bank transfers.</p>''',
'is_published': True,
'is_featured': False,
},
{
'title': 'Security Best Practices',
'slug': 'security-best-practices',
'category': KnowledgeBaseCategory.objects.filter(slug='security-privacy').first(),
'summary': 'Essential security practices to keep your account safe.',
'content': '''<h2>Account Security</h2>
<p>Follow these best practices to keep your account secure.</p>
<h3>Use Strong Passwords</h3>
<p>Create complex passwords with a mix of letters, numbers, and symbols.</p>
<h3>Enable Two-Factor Authentication</h3>
<p>Add an extra layer of security with 2FA.</p>
<h3>Regular Security Audits</h3>
<p>Review your account activity regularly for any suspicious behavior.</p>
<h3>Keep Software Updated</h3>
<p>Always use the latest version of our software for the best security.</p>''',
'is_published': True,
'is_featured': True,
},
]
for article_data in articles:
if article_data['category']:
article, created = KnowledgeBaseArticle.objects.get_or_create(
slug=article_data['slug'],
defaults=article_data
)
if created:
self.stdout.write(self.style.SUCCESS(f' ✓ Created article: {article.title}'))
else:
self.stdout.write(f' - Article already exists: {article.title}')

View File

@@ -0,0 +1,183 @@
# Generated by Django 4.2.7 on 2025-10-07 15:42
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SupportSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('setting_name', models.CharField(max_length=100, unique=True)),
('setting_value', models.TextField()),
('description', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name_plural': 'Support Settings',
},
),
migrations.CreateModel(
name='TicketCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('description', models.TextField(blank=True)),
('color', models.CharField(default='#667eea', help_text='Hex color code', max_length=7)),
('icon', models.CharField(default='fa-question-circle', help_text='FontAwesome icon class', max_length=50)),
('is_active', models.BooleanField(default=True)),
('display_order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name_plural': 'Ticket Categories',
'ordering': ['display_order', 'name'],
},
),
migrations.CreateModel(
name='TicketPriority',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('level', models.PositiveIntegerField(help_text='Lower number = higher priority', unique=True)),
('color', models.CharField(default='#667eea', help_text='Hex color code', max_length=7)),
('description', models.TextField(blank=True)),
('sla_hours', models.PositiveIntegerField(default=24, help_text='SLA response time in hours')),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name_plural': 'Ticket Priorities',
'ordering': ['level'],
},
),
migrations.CreateModel(
name='TicketStatus',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('color', models.CharField(default='#667eea', help_text='Hex color code', max_length=7)),
('description', models.TextField(blank=True)),
('is_closed', models.BooleanField(default=False, help_text='Whether this status represents a closed ticket')),
('is_active', models.BooleanField(default=True)),
('display_order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name_plural': 'Ticket Statuses',
'ordering': ['display_order', 'name'],
},
),
migrations.CreateModel(
name='SupportTicket',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ticket_number', models.CharField(editable=False, max_length=20, unique=True)),
('title', models.CharField(max_length=200)),
('description', models.TextField()),
('ticket_type', models.CharField(choices=[('technical', 'Technical Issue'), ('billing', 'Billing Question'), ('feature_request', 'Feature Request'), ('bug_report', 'Bug Report'), ('general', 'General Inquiry'), ('account', 'Account Issue')], default='general', max_length=20)),
('user_name', models.CharField(max_length=100)),
('user_email', models.EmailField(max_length=254)),
('user_phone', models.CharField(blank=True, max_length=20)),
('company', models.CharField(blank=True, max_length=100)),
('assigned_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('closed_at', models.DateTimeField(blank=True, null=True)),
('last_activity', models.DateTimeField(auto_now=True)),
('first_response_at', models.DateTimeField(blank=True, null=True)),
('sla_deadline', models.DateTimeField(blank=True, null=True)),
('tags', models.CharField(blank=True, help_text='Comma-separated tags', max_length=500)),
('internal_notes', models.TextField(blank=True, help_text='Internal notes visible only to staff')),
('is_escalated', models.BooleanField(default=False)),
('escalation_reason', models.TextField(blank=True)),
('attachments', models.JSONField(blank=True, default=list, help_text='List of file paths')),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tickets', to=settings.AUTH_USER_MODEL)),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='support.ticketcategory')),
('priority', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='support.ticketpriority')),
('status', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='support.ticketstatus')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='support_tickets', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='TicketMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message_type', models.CharField(choices=[('user_message', 'User Message'), ('agent_response', 'Agent Response'), ('system_note', 'System Note'), ('status_change', 'Status Change'), ('assignment_change', 'Assignment Change'), ('escalation', 'Escalation')], default='user_message', max_length=20)),
('content', models.TextField()),
('author_name', models.CharField(blank=True, max_length=100)),
('author_email', models.EmailField(blank=True, max_length=254)),
('is_internal', models.BooleanField(default=False, help_text='Internal message not visible to user')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('attachments', models.JSONField(blank=True, default=list, help_text='List of file paths')),
('is_read', models.BooleanField(default=False)),
('read_at', models.DateTimeField(blank=True, null=True)),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_messages', to=settings.AUTH_USER_MODEL)),
('read_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='read_messages', to=settings.AUTH_USER_MODEL)),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='support.supportticket')),
],
options={
'ordering': ['created_at'],
'indexes': [models.Index(fields=['ticket', 'created_at'], name='support_tic_ticket__0cd9bd_idx'), models.Index(fields=['author'], name='support_tic_author__503d4b_idx'), models.Index(fields=['message_type'], name='support_tic_message_6220bd_idx')],
},
),
migrations.CreateModel(
name='TicketActivity',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('activity_type', models.CharField(choices=[('created', 'Ticket Created'), ('updated', 'Ticket Updated'), ('status_changed', 'Status Changed'), ('assigned', 'Ticket Assigned'), ('message_added', 'Message Added'), ('escalated', 'Ticket Escalated'), ('closed', 'Ticket Closed'), ('reopened', 'Ticket Reopened')], max_length=20)),
('description', models.TextField()),
('user_name', models.CharField(blank=True, max_length=100)),
('old_value', models.TextField(blank=True)),
('new_value', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='support.supportticket')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_activities', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['ticket', 'created_at'], name='support_tic_ticket__4097ca_idx'), models.Index(fields=['activity_type'], name='support_tic_activit_9c98a0_idx')],
},
),
migrations.AddIndex(
model_name='supportticket',
index=models.Index(fields=['ticket_number'], name='support_sup_ticket__4a7d4b_idx'),
),
migrations.AddIndex(
model_name='supportticket',
index=models.Index(fields=['user_email'], name='support_sup_user_em_c518a8_idx'),
),
migrations.AddIndex(
model_name='supportticket',
index=models.Index(fields=['status'], name='support_sup_status__7b4480_idx'),
),
migrations.AddIndex(
model_name='supportticket',
index=models.Index(fields=['priority'], name='support_sup_priorit_5d48ff_idx'),
),
migrations.AddIndex(
model_name='supportticket',
index=models.Index(fields=['assigned_to'], name='support_sup_assigne_53b075_idx'),
),
migrations.AddIndex(
model_name='supportticket',
index=models.Index(fields=['created_at'], name='support_sup_created_83a137_idx'),
),
]

View File

@@ -0,0 +1,61 @@
# Generated by Django 4.2.7 on 2025-10-07 18:06
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('support', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='KnowledgeBaseCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=120, unique=True)),
('description', models.TextField(blank=True)),
('icon', models.CharField(default='fa-book', help_text='FontAwesome icon class', max_length=50)),
('color', models.CharField(default='#667eea', help_text='Hex color code', max_length=7)),
('is_active', models.BooleanField(default=True)),
('display_order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name_plural': 'Knowledge Base Categories',
'ordering': ['display_order', 'name'],
},
),
migrations.CreateModel(
name='KnowledgeBaseArticle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('slug', models.SlugField(max_length=220, unique=True)),
('content', models.TextField()),
('summary', models.TextField(blank=True, help_text='Short summary of the article')),
('meta_description', models.CharField(blank=True, max_length=160)),
('keywords', models.CharField(blank=True, help_text='Comma-separated keywords', max_length=500)),
('is_published', models.BooleanField(default=False)),
('is_featured', models.BooleanField(default=False)),
('view_count', models.PositiveIntegerField(default=0)),
('helpful_count', models.PositiveIntegerField(default=0)),
('not_helpful_count', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('published_at', models.DateTimeField(blank=True, null=True)),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='kb_articles', to=settings.AUTH_USER_MODEL)),
('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='articles', to='support.knowledgebasecategory')),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['slug'], name='support_kno_slug_0c7b3a_idx'), models.Index(fields=['category'], name='support_kno_categor_733ba1_idx'), models.Index(fields=['is_published'], name='support_kno_is_publ_402a55_idx'), models.Index(fields=['created_at'], name='support_kno_created_ef91a5_idx')],
},
),
]

View File

@@ -0,0 +1,39 @@
# Generated by Django 4.2.7 on 2025-10-07 18:58
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('support', '0002_knowledgebasecategory_knowledgebasearticle'),
]
operations = [
migrations.CreateModel(
name='RegisteredEmail',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()])),
('company_name', models.CharField(blank=True, help_text='Company or organization name', max_length=200)),
('contact_name', models.CharField(blank=True, help_text='Primary contact name', max_length=200)),
('notes', models.TextField(blank=True, help_text='Internal notes about this registration')),
('is_active', models.BooleanField(default=True, help_text='Whether this email can submit tickets')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('last_ticket_date', models.DateTimeField(blank=True, help_text='Last time this email submitted a ticket', null=True)),
('ticket_count', models.PositiveIntegerField(default=0, help_text='Total number of tickets submitted')),
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='registered_emails', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Registered Email',
'verbose_name_plural': 'Registered Emails',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['email'], name='support_reg_email_ee78ad_idx'), models.Index(fields=['is_active'], name='support_reg_is_acti_fadc86_idx')],
},
),
]

View File

@@ -0,0 +1,320 @@
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
from django.core.validators import EmailValidator
import random
import string
class TicketStatus(models.Model):
name = models.CharField(max_length=50, unique=True)
color = models.CharField(max_length=7, default='#667eea', help_text='Hex color code')
description = models.TextField(blank=True)
is_closed = models.BooleanField(default=False, help_text='Whether this status represents a closed ticket')
is_active = models.BooleanField(default=True)
display_order = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['display_order', 'name']
verbose_name_plural = 'Ticket Statuses'
def __str__(self):
return self.name
class TicketPriority(models.Model):
name = models.CharField(max_length=50, unique=True)
level = models.PositiveIntegerField(unique=True, help_text='Lower number = higher priority')
color = models.CharField(max_length=7, default='#667eea', help_text='Hex color code')
description = models.TextField(blank=True)
sla_hours = models.PositiveIntegerField(default=24, help_text='SLA response time in hours')
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['level']
verbose_name_plural = 'Ticket Priorities'
def __str__(self):
return self.name
class TicketCategory(models.Model):
name = models.CharField(max_length=100, unique=True)
description = models.TextField(blank=True)
color = models.CharField(max_length=7, default='#667eea', help_text='Hex color code')
icon = models.CharField(max_length=50, default='fa-question-circle', help_text='FontAwesome icon class')
is_active = models.BooleanField(default=True)
display_order = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['display_order', 'name']
verbose_name_plural = 'Ticket Categories'
def __str__(self):
return self.name
class SupportTicket(models.Model):
TICKET_TYPES = [
('technical', 'Technical Issue'),
('billing', 'Billing Question'),
('feature_request', 'Feature Request'),
('bug_report', 'Bug Report'),
('general', 'General Inquiry'),
('account', 'Account Issue'),
]
ticket_number = models.CharField(max_length=20, unique=True, editable=False)
title = models.CharField(max_length=200)
description = models.TextField()
ticket_type = models.CharField(max_length=20, choices=TICKET_TYPES, default='general')
# User information
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='support_tickets', null=True, blank=True)
user_name = models.CharField(max_length=100)
user_email = models.EmailField()
user_phone = models.CharField(max_length=20, blank=True)
company = models.CharField(max_length=100, blank=True)
# Ticket management
category = models.ForeignKey(TicketCategory, on_delete=models.SET_NULL, null=True, blank=True)
priority = models.ForeignKey(TicketPriority, on_delete=models.SET_NULL, null=True, blank=True)
status = models.ForeignKey(TicketStatus, on_delete=models.SET_NULL, null=True, blank=True)
assigned_to = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_tickets')
assigned_at = models.DateTimeField(null=True, blank=True)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
closed_at = models.DateTimeField(null=True, blank=True)
last_activity = models.DateTimeField(auto_now=True)
first_response_at = models.DateTimeField(null=True, blank=True)
sla_deadline = models.DateTimeField(null=True, blank=True)
# Additional fields
tags = models.CharField(max_length=500, blank=True, help_text='Comma-separated tags')
internal_notes = models.TextField(blank=True, help_text='Internal notes visible only to staff')
is_escalated = models.BooleanField(default=False)
escalation_reason = models.TextField(blank=True)
attachments = models.JSONField(default=list, blank=True, help_text='List of file paths')
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['ticket_number']),
models.Index(fields=['user_email']),
models.Index(fields=['status']),
models.Index(fields=['priority']),
models.Index(fields=['assigned_to']),
models.Index(fields=['created_at']),
]
def __str__(self):
return f"{self.ticket_number} - {self.title}"
def save(self, *args, **kwargs):
if not self.ticket_number:
self.ticket_number = self.generate_ticket_number()
# Set SLA deadline based on priority
if not self.sla_deadline and self.priority:
self.sla_deadline = timezone.now() + timezone.timedelta(hours=self.priority.sla_hours)
super().save(*args, **kwargs)
@staticmethod
def generate_ticket_number():
"""Generate a unique ticket number in format: TKT-YYYYMMDD-XXXXX"""
today = timezone.now().strftime('%Y%m%d')
random_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=5))
ticket_number = f'TKT-{today}-{random_str}'
# Ensure uniqueness
while SupportTicket.objects.filter(ticket_number=ticket_number).exists():
random_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=5))
ticket_number = f'TKT-{today}-{random_str}'
return ticket_number
class TicketMessage(models.Model):
MESSAGE_TYPES = [
('user_message', 'User Message'),
('agent_response', 'Agent Response'),
('system_note', 'System Note'),
('status_change', 'Status Change'),
('assignment_change', 'Assignment Change'),
('escalation', 'Escalation'),
]
ticket = models.ForeignKey(SupportTicket, on_delete=models.CASCADE, related_name='messages')
message_type = models.CharField(max_length=20, choices=MESSAGE_TYPES, default='user_message')
content = models.TextField()
# Author information
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='ticket_messages')
author_name = models.CharField(max_length=100, blank=True)
author_email = models.EmailField(blank=True)
# Message metadata
is_internal = models.BooleanField(default=False, help_text='Internal message not visible to user')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
attachments = models.JSONField(default=list, blank=True, help_text='List of file paths')
# Read status
is_read = models.BooleanField(default=False)
read_at = models.DateTimeField(null=True, blank=True)
read_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='read_messages')
class Meta:
ordering = ['created_at']
indexes = [
models.Index(fields=['ticket', 'created_at']),
models.Index(fields=['author']),
models.Index(fields=['message_type']),
]
def __str__(self):
return f"Message on {self.ticket.ticket_number} at {self.created_at}"
class TicketActivity(models.Model):
ACTIVITY_TYPES = [
('created', 'Ticket Created'),
('updated', 'Ticket Updated'),
('status_changed', 'Status Changed'),
('assigned', 'Ticket Assigned'),
('message_added', 'Message Added'),
('escalated', 'Ticket Escalated'),
('closed', 'Ticket Closed'),
('reopened', 'Ticket Reopened'),
]
ticket = models.ForeignKey(SupportTicket, on_delete=models.CASCADE, related_name='activities')
activity_type = models.CharField(max_length=20, choices=ACTIVITY_TYPES)
description = models.TextField()
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='ticket_activities')
user_name = models.CharField(max_length=100, blank=True)
old_value = models.TextField(blank=True)
new_value = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['ticket', 'created_at']),
models.Index(fields=['activity_type']),
]
def __str__(self):
return f"{self.activity_type} - {self.ticket.ticket_number}"
class KnowledgeBaseCategory(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(max_length=120, unique=True)
description = models.TextField(blank=True)
icon = models.CharField(max_length=50, default='fa-book', help_text='FontAwesome icon class')
color = models.CharField(max_length=7, default='#667eea', help_text='Hex color code')
is_active = models.BooleanField(default=True)
display_order = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['display_order', 'name']
verbose_name_plural = 'Knowledge Base Categories'
def __str__(self):
return self.name
class KnowledgeBaseArticle(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
category = models.ForeignKey(KnowledgeBaseCategory, on_delete=models.SET_NULL, null=True, related_name='articles')
content = models.TextField()
summary = models.TextField(blank=True, help_text='Short summary of the article')
# SEO and metadata
meta_description = models.CharField(max_length=160, blank=True)
keywords = models.CharField(max_length=500, blank=True, help_text='Comma-separated keywords')
# Article management
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='kb_articles')
is_published = models.BooleanField(default=False)
is_featured = models.BooleanField(default=False)
view_count = models.PositiveIntegerField(default=0)
helpful_count = models.PositiveIntegerField(default=0)
not_helpful_count = models.PositiveIntegerField(default=0)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
published_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['slug']),
models.Index(fields=['category']),
models.Index(fields=['is_published']),
models.Index(fields=['created_at']),
]
def __str__(self):
return self.title
class SupportSettings(models.Model):
setting_name = models.CharField(max_length=100, unique=True)
setting_value = models.TextField()
description = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name_plural = 'Support Settings'
def __str__(self):
return self.setting_name
class RegisteredEmail(models.Model):
"""
Email addresses that are authorized to submit support tickets
Only admins can add/remove emails from this list
"""
email = models.EmailField(unique=True, validators=[EmailValidator()])
company_name = models.CharField(max_length=200, blank=True, help_text='Company or organization name')
contact_name = models.CharField(max_length=200, blank=True, help_text='Primary contact name')
notes = models.TextField(blank=True, help_text='Internal notes about this registration')
is_active = models.BooleanField(default=True, help_text='Whether this email can submit tickets')
added_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='registered_emails')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
last_ticket_date = models.DateTimeField(null=True, blank=True, help_text='Last time this email submitted a ticket')
ticket_count = models.PositiveIntegerField(default=0, help_text='Total number of tickets submitted')
class Meta:
ordering = ['-created_at']
verbose_name = 'Registered Email'
verbose_name_plural = 'Registered Emails'
indexes = [
models.Index(fields=['email']),
models.Index(fields=['is_active']),
]
def __str__(self):
return f"{self.email} ({self.company_name or 'No company'})"
def increment_ticket_count(self):
"""Increment ticket count and update last ticket date"""
self.ticket_count += 1
self.last_ticket_date = timezone.now()
self.save(update_fields=['ticket_count', 'last_ticket_date'])

View File

@@ -0,0 +1,204 @@
from rest_framework import serializers
from .models import (
SupportTicket, TicketStatus, TicketPriority, TicketCategory,
TicketMessage, TicketActivity, KnowledgeBaseCategory,
KnowledgeBaseArticle, SupportSettings
)
from .email_service import SupportEmailService
import logging
logger = logging.getLogger(__name__)
class TicketStatusSerializer(serializers.ModelSerializer):
class Meta:
model = TicketStatus
fields = ['id', 'name', 'color', 'description', 'is_closed', 'display_order']
class TicketPrioritySerializer(serializers.ModelSerializer):
class Meta:
model = TicketPriority
fields = ['id', 'name', 'level', 'color', 'description', 'sla_hours']
class TicketCategorySerializer(serializers.ModelSerializer):
class Meta:
model = TicketCategory
fields = ['id', 'name', 'description', 'color', 'icon', 'display_order']
class TicketMessageSerializer(serializers.ModelSerializer):
class Meta:
model = TicketMessage
fields = [
'id', 'ticket', 'message_type', 'content', 'author_name',
'author_email', 'is_internal', 'created_at', 'updated_at',
'attachments', 'is_read'
]
read_only_fields = ['created_at', 'updated_at']
class TicketActivitySerializer(serializers.ModelSerializer):
class Meta:
model = TicketActivity
fields = [
'id', 'activity_type', 'description', 'user_name',
'old_value', 'new_value', 'created_at'
]
read_only_fields = ['created_at']
class SupportTicketSerializer(serializers.ModelSerializer):
status_name = serializers.CharField(source='status.name', read_only=True)
status_color = serializers.CharField(source='status.color', read_only=True)
priority_name = serializers.CharField(source='priority.name', read_only=True)
priority_color = serializers.CharField(source='priority.color', read_only=True)
category_name = serializers.CharField(source='category.name', read_only=True)
messages = TicketMessageSerializer(many=True, read_only=True)
activities = TicketActivitySerializer(many=True, read_only=True)
class Meta:
model = SupportTicket
fields = [
'id', 'ticket_number', 'title', 'description', 'ticket_type',
'user_name', 'user_email', 'user_phone', 'company',
'category', 'category_name', 'priority', 'priority_name', 'priority_color',
'status', 'status_name', 'status_color', 'assigned_to', 'assigned_at',
'created_at', 'updated_at', 'closed_at', 'last_activity',
'first_response_at', 'sla_deadline', 'tags', 'is_escalated',
'escalation_reason', 'attachments', 'messages', 'activities'
]
read_only_fields = [
'ticket_number', 'created_at', 'updated_at', 'last_activity',
'assigned_at', 'closed_at', 'first_response_at', 'sla_deadline'
]
class SupportTicketCreateSerializer(serializers.ModelSerializer):
"""Simplified serializer for creating tickets from public form"""
ticket_number = serializers.CharField(read_only=True)
class Meta:
model = SupportTicket
fields = [
'ticket_number', 'title', 'description', 'ticket_type', 'user_name',
'user_email', 'user_phone', 'company', 'category'
]
read_only_fields = ['ticket_number']
def validate_user_email(self, value):
"""
Validate that the email is registered and active in the RegisteredEmail model
"""
from .models import RegisteredEmail
# Check if email exists and is active in RegisteredEmail model
try:
registered_email = RegisteredEmail.objects.get(email=value)
if not registered_email.is_active:
raise serializers.ValidationError(
"This email has been deactivated. "
"Please contact us at support@gnxsoft.com for assistance."
)
except RegisteredEmail.DoesNotExist:
raise serializers.ValidationError(
"This email is not registered in our system. "
"Please contact us at support@gnxsoft.com to register your email first."
)
return value
def create(self, validated_data):
from .models import RegisteredEmail
# Set default status and priority if not set
if not validated_data.get('status'):
default_status = TicketStatus.objects.filter(name='Open').first()
if default_status:
validated_data['status'] = default_status
if not validated_data.get('priority'):
default_priority = TicketPriority.objects.filter(name='Medium').first()
if default_priority:
validated_data['priority'] = default_priority
ticket = SupportTicket.objects.create(**validated_data)
# Create initial activity
TicketActivity.objects.create(
ticket=ticket,
activity_type='created',
description=f'Ticket created by {ticket.user_name}',
user_name=ticket.user_name
)
# Update registered email statistics
try:
registered_email = RegisteredEmail.objects.get(email=validated_data['user_email'])
registered_email.increment_ticket_count()
except RegisteredEmail.DoesNotExist:
logger.warning(f'RegisteredEmail not found for {validated_data["user_email"]} after validation')
# Send email notifications
try:
email_results = SupportEmailService.send_ticket_created_emails(ticket)
logger.info(f'Email notifications sent for ticket {ticket.ticket_number}: {email_results}')
except Exception as e:
logger.error(f'Failed to send email notifications for ticket {ticket.ticket_number}: {str(e)}')
# Don't fail ticket creation if emails fail
return ticket
class TicketStatusCheckSerializer(serializers.Serializer):
"""Serializer for checking ticket status by ticket number"""
ticket_number = serializers.CharField(max_length=20)
class KnowledgeBaseCategorySerializer(serializers.ModelSerializer):
article_count = serializers.SerializerMethodField()
class Meta:
model = KnowledgeBaseCategory
fields = [
'id', 'name', 'slug', 'description', 'icon', 'color',
'display_order', 'article_count'
]
def get_article_count(self, obj):
return obj.articles.filter(is_published=True).count()
class KnowledgeBaseArticleListSerializer(serializers.ModelSerializer):
category_name = serializers.CharField(source='category.name', read_only=True)
category_slug = serializers.CharField(source='category.slug', read_only=True)
class Meta:
model = KnowledgeBaseArticle
fields = [
'id', 'title', 'slug', 'category', 'category_name', 'category_slug',
'summary', 'is_featured', 'view_count', 'helpful_count',
'not_helpful_count', 'created_at', 'updated_at', 'published_at'
]
class KnowledgeBaseArticleDetailSerializer(serializers.ModelSerializer):
category_name = serializers.CharField(source='category.name', read_only=True)
category_slug = serializers.CharField(source='category.slug', read_only=True)
class Meta:
model = KnowledgeBaseArticle
fields = [
'id', 'title', 'slug', 'category', 'category_name', 'category_slug',
'content', 'summary', 'meta_description', 'keywords',
'is_featured', 'view_count', 'helpful_count', 'not_helpful_count',
'created_at', 'updated_at', 'published_at'
]
class SupportSettingsSerializer(serializers.ModelSerializer):
class Meta:
model = SupportSettings
fields = ['id', 'setting_name', 'setting_value', 'description', 'is_active']

View File

@@ -0,0 +1,235 @@
"""
Django signals for Support app
Handles automatic notifications when tickets are updated
"""
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.conf import settings
import logging
from .models import SupportTicket, TicketMessage, TicketActivity
logger = logging.getLogger(__name__)
class TicketUpdateNotifier:
"""Service for sending ticket update notifications"""
@staticmethod
def send_status_change_notification(ticket, old_status, new_status):
"""Send email when ticket status changes"""
try:
subject = f'Ticket Status Updated: {ticket.ticket_number}'
context = {
'ticket': ticket,
'ticket_number': ticket.ticket_number,
'user_name': ticket.user_name,
'title': ticket.title,
'old_status': old_status,
'new_status': new_status,
'status_color': ticket.status.color if ticket.status else '#3b82f6',
'updated_at': ticket.updated_at.strftime('%B %d, %Y at %I:%M %p'),
'support_url': f'{settings.SITE_URL}/support-center',
}
# Render HTML email
html_message = render_to_string(
'support/ticket_status_update.html',
context
)
# Create plain text version
text_message = render_to_string(
'support/ticket_status_update.txt',
context
)
# Create email
email = EmailMultiAlternatives(
subject=subject,
body=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[ticket.user_email],
)
email.attach_alternative(html_message, "text/html")
email.send(fail_silently=False)
logger.info(f'Status change notification sent for ticket {ticket.ticket_number}')
return True
except Exception as e:
logger.error(f'Failed to send status change notification: {str(e)}')
return False
@staticmethod
def send_message_notification(ticket, message):
"""Send email when a new message is added to the ticket"""
try:
# Only send if it's an agent response (not user message)
if message.message_type != 'agent_response':
return False
subject = f'New Response on Ticket: {ticket.ticket_number}'
context = {
'ticket': ticket,
'ticket_number': ticket.ticket_number,
'user_name': ticket.user_name,
'title': ticket.title,
'message': message.content,
'message_author': message.author_name or 'Support Team',
'created_at': message.created_at.strftime('%B %d, %Y at %I:%M %p'),
'support_url': f'{settings.SITE_URL}/support-center',
}
# Render HTML email
html_message = render_to_string(
'support/ticket_message_notification.html',
context
)
# Create plain text version
text_message = render_to_string(
'support/ticket_message_notification.txt',
context
)
# Create email
email = EmailMultiAlternatives(
subject=subject,
body=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[ticket.user_email],
)
email.attach_alternative(html_message, "text/html")
email.send(fail_silently=False)
logger.info(f'Message notification sent for ticket {ticket.ticket_number}')
return True
except Exception as e:
logger.error(f'Failed to send message notification: {str(e)}')
return False
@staticmethod
def send_assignment_notification(ticket, assigned_to):
"""Send email when ticket is assigned"""
try:
subject = f'Ticket Assigned: {ticket.ticket_number}'
context = {
'ticket': ticket,
'ticket_number': ticket.ticket_number,
'user_name': ticket.user_name,
'title': ticket.title,
'assigned_to': assigned_to.get_full_name() or assigned_to.username,
'updated_at': ticket.updated_at.strftime('%B %d, %Y at %I:%M %p'),
'support_url': f'{settings.SITE_URL}/support-center',
}
# Render HTML email
html_message = render_to_string(
'support/ticket_assigned_notification.html',
context
)
# Create plain text version
text_message = render_to_string(
'support/ticket_assigned_notification.txt',
context
)
# Create email
email = EmailMultiAlternatives(
subject=subject,
body=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[ticket.user_email],
)
email.attach_alternative(html_message, "text/html")
email.send(fail_silently=False)
logger.info(f'Assignment notification sent for ticket {ticket.ticket_number}')
return True
except Exception as e:
logger.error(f'Failed to send assignment notification: {str(e)}')
return False
# Store original values before save
@receiver(pre_save, sender=SupportTicket)
def store_ticket_original_values(sender, instance, **kwargs):
"""Store original values before ticket is updated"""
if instance.pk:
try:
original = SupportTicket.objects.get(pk=instance.pk)
instance._original_status = original.status
instance._original_assigned_to = original.assigned_to
except SupportTicket.DoesNotExist:
pass
# Send notifications after ticket is saved
@receiver(post_save, sender=SupportTicket)
def send_ticket_update_notifications(sender, instance, created, **kwargs):
"""Send notifications when ticket is updated"""
if created:
# Ticket creation is handled in serializer
return
# Check for status change
if hasattr(instance, '_original_status') and instance._original_status != instance.status:
old_status = instance._original_status.name if instance._original_status else 'Unknown'
new_status = instance.status.name if instance.status else 'Unknown'
TicketUpdateNotifier.send_status_change_notification(
instance,
old_status,
new_status
)
# Create activity log
TicketActivity.objects.create(
ticket=instance,
activity_type='status_changed',
description=f'Status changed from {old_status} to {new_status}',
old_value=old_status,
new_value=new_status
)
# Check for assignment change
if hasattr(instance, '_original_assigned_to') and instance._original_assigned_to != instance.assigned_to:
if instance.assigned_to:
TicketUpdateNotifier.send_assignment_notification(
instance,
instance.assigned_to
)
# Create activity log
TicketActivity.objects.create(
ticket=instance,
activity_type='assigned',
description=f'Ticket assigned to {instance.assigned_to.get_full_name() or instance.assigned_to.username}',
new_value=str(instance.assigned_to)
)
# Send notification when message is added
@receiver(post_save, sender=TicketMessage)
def send_message_notification(sender, instance, created, **kwargs):
"""Send notification when a new message is added"""
if created and not instance.is_internal:
# Only send if it's a new message that's not internal
TicketUpdateNotifier.send_message_notification(
instance.ticket,
instance
)

View File

@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ticket Assigned</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333333;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.email-header {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: #ffffff;
padding: 30px;
text-align: center;
}
.email-header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.email-body {
padding: 40px 30px;
}
.ticket-info {
background: linear-gradient(135deg, rgba(218, 165, 32, 0.1) 0%, rgba(218, 165, 32, 0.05) 100%);
border-left: 4px solid #daa520;
padding: 20px;
margin: 25px 0;
border-radius: 4px;
}
.assignment-box {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.1) 0%, rgba(139, 92, 246, 0.05) 100%);
border-left: 4px solid #8b5cf6;
padding: 20px;
margin: 25px 0;
border-radius: 4px;
text-align: center;
}
.cta-button {
display: inline-block;
background: linear-gradient(135deg, #daa520, #d4af37);
color: #ffffff !important;
text-decoration: none;
padding: 14px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
}
.footer {
background-color: #f8f8f8;
padding: 25px 30px;
text-align: center;
font-size: 14px;
color: #666666;
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
<h1>👤 Ticket Assigned</h1>
</div>
<div class="email-body">
<p>Dear {{ user_name }},</p>
<p>Your support ticket has been assigned to a team member who will be assisting you.</p>
<div class="ticket-info">
<strong>Ticket:</strong> {{ ticket_number }}<br>
<strong>Subject:</strong> {{ title }}
</div>
<div class="assignment-box">
<h3 style="margin-top: 0;">Assigned To</h3>
<div style="font-size: 18px; font-weight: 700; color: #8b5cf6;">{{ assigned_to }}</div>
<p style="margin-top: 10px; color: #666; font-size: 14px;">{{ updated_at }}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ support_url }}" class="cta-button">View Ticket Details</a>
</div>
<p style="color: #666;">
Your assigned support agent will review your ticket and respond as soon as possible.
</p>
</div>
<div class="footer">
<p style="margin: 0 0 10px 0;">
<strong>GNX Software Solutions</strong><br>
Enterprise Support Center
</p>
<p style="margin: 0; font-size: 12px;">
This is an automated notification. For assistance, visit our <a href="{{ support_url }}" style="color: #daa520;">Support Center</a>
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,31 @@
TICKET ASSIGNED
============================================================
Dear {{ user_name }},
Your support ticket has been assigned to a team member who will be assisting you.
Ticket: {{ ticket_number }}
Subject: {{ title }}
------------------------------------------------------------
ASSIGNED TO
------------------------------------------------------------
{{ assigned_to }}
{{ updated_at }}
------------------------------------------------------------
Your assigned support agent will review your ticket and respond as soon as possible.
View ticket details:
{{ support_url }}
============================================================
GNX Software Solutions - Enterprise Support Center
This is an automated notification.
For assistance, visit our Support Center at {{ support_url }}

View File

@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Support Ticket Confirmation</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333333;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.email-header {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: #ffffff;
padding: 30px;
text-align: center;
}
.email-header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.email-body {
padding: 40px 30px;
}
.ticket-number-box {
background: linear-gradient(135deg, rgba(218, 165, 32, 0.1) 0%, rgba(218, 165, 32, 0.05) 100%);
border-left: 4px solid #daa520;
padding: 20px;
margin: 25px 0;
border-radius: 4px;
}
.ticket-number {
font-size: 24px;
font-weight: 700;
color: #daa520;
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
.info-section {
margin: 25px 0;
}
.info-row {
padding: 12px 0;
border-bottom: 1px solid #e5e5e5;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-weight: 600;
color: #555555;
display: inline-block;
width: 120px;
}
.info-value {
color: #333333;
}
.cta-button {
display: inline-block;
background: linear-gradient(135deg, #daa520, #d4af37);
color: #ffffff !important;
text-decoration: none;
padding: 14px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
}
.footer {
background-color: #f8f8f8;
padding: 25px 30px;
text-align: center;
font-size: 14px;
color: #666666;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
background-color: #3b82f6;
color: white;
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="email-header">
<h1>✓ Support Ticket Created</h1>
</div>
<!-- Body -->
<div class="email-body">
<p>Dear {{ user_name }},</p>
<p>Thank you for contacting our support team. We've received your support request and our team will respond as soon as possible.</p>
<!-- Ticket Number Box -->
<div class="ticket-number-box">
<div style="font-size: 14px; color: #666; margin-bottom: 8px;">Your Ticket Number</div>
<div class="ticket-number">{{ ticket_number }}</div>
<div style="font-size: 13px; color: #666; margin-top: 8px;">Please save this number for future reference</div>
</div>
<!-- Ticket Details -->
<div class="info-section">
<h3 style="color: #0f172a; margin-bottom: 15px;">Ticket Details</h3>
<div class="info-row">
<span class="info-label">Subject:</span>
<span class="info-value">{{ title }}</span>
</div>
<div class="info-row">
<span class="info-label">Type:</span>
<span class="info-value">{{ ticket_type }}</span>
</div>
<div class="info-row">
<span class="info-label">Category:</span>
<span class="info-value">{{ category }}</span>
</div>
<div class="info-row">
<span class="info-label">Priority:</span>
<span class="info-value">{{ priority }}</span>
</div>
<div class="info-row">
<span class="info-label">Status:</span>
<span class="status-badge">{{ status }}</span>
</div>
<div class="info-row">
<span class="info-label">Created:</span>
<span class="info-value">{{ created_at }}</span>
</div>
</div>
<!-- Description -->
<div class="info-section">
<h3 style="color: #0f172a; margin-bottom: 15px;">Description</h3>
<div style="background-color: #f8f8f8; padding: 15px; border-radius: 4px; white-space: pre-wrap;">{{ description }}</div>
</div>
<!-- CTA Button -->
<div style="text-align: center; margin: 30px 0;">
<a href="{{ support_url }}" class="cta-button">Check Ticket Status</a>
</div>
<p style="margin-top: 30px; color: #666;">
<strong>What happens next?</strong><br>
Our support team will review your ticket and respond within our standard SLA timeframe. You'll receive an email notification when there's an update.
</p>
</div>
<!-- Footer -->
<div class="footer">
<p style="margin: 0 0 10px 0;">
<strong>GNX Software Solutions</strong><br>
Enterprise Support Center
</p>
<p style="margin: 0; font-size: 12px;">
This is an automated message. Please do not reply directly to this email.<br>
For assistance, please visit our <a href="{{ support_url }}" style="color: #daa520;">Support Center</a>
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,41 @@
SUPPORT TICKET CREATED
============================================================
Dear {{ user_name }},
Thank you for contacting our support team. We've received your support request and our team will respond as soon as possible.
YOUR TICKET NUMBER: {{ ticket_number }}
Please save this number for future reference.
------------------------------------------------------------
TICKET DETAILS
------------------------------------------------------------
Subject: {{ title }}
Type: {{ ticket_type }}
Category: {{ category }}
Priority: {{ priority }}
Status: {{ status }}
Created: {{ created_at }}
------------------------------------------------------------
DESCRIPTION
------------------------------------------------------------
{{ description }}
------------------------------------------------------------
WHAT HAPPENS NEXT?
Our support team will review your ticket and respond within our standard SLA timeframe. You'll receive an email notification when there's an update.
To check your ticket status, visit:
{{ support_url }}
============================================================
GNX Software Solutions - Enterprise Support Center
This is an automated message. Please do not reply directly to this email.
For assistance, please visit our Support Center at {{ support_url }}

View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Response on Your Ticket</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333333;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.email-header {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: #ffffff;
padding: 30px;
text-align: center;
}
.email-header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.email-body {
padding: 40px 30px;
}
.ticket-info {
background: linear-gradient(135deg, rgba(218, 165, 32, 0.1) 0%, rgba(218, 165, 32, 0.05) 100%);
border-left: 4px solid #daa520;
padding: 20px;
margin: 25px 0;
border-radius: 4px;
}
.message-box {
background: #f8f9fa;
border-left: 4px solid #10b981;
padding: 20px;
margin: 25px 0;
border-radius: 4px;
}
.message-author {
font-weight: 700;
color: #10b981;
margin-bottom: 10px;
}
.message-content {
color: #333;
white-space: pre-wrap;
line-height: 1.8;
}
.cta-button {
display: inline-block;
background: linear-gradient(135deg, #daa520, #d4af37);
color: #ffffff !important;
text-decoration: none;
padding: 14px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
}
.footer {
background-color: #f8f8f8;
padding: 25px 30px;
text-align: center;
font-size: 14px;
color: #666666;
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
<h1>💬 New Response on Your Ticket</h1>
</div>
<div class="email-body">
<p>Dear {{ user_name }},</p>
<p>Our support team has responded to your ticket.</p>
<div class="ticket-info">
<strong>Ticket:</strong> {{ ticket_number }}<br>
<strong>Subject:</strong> {{ title }}
</div>
<div class="message-box">
<div class="message-author">{{ message_author }} replied:</div>
<div class="message-content">{{ message }}</div>
<div style="margin-top: 15px; color: #666; font-size: 13px;">{{ created_at }}</div>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ support_url }}" class="cta-button">View Full Conversation</a>
</div>
<p style="color: #666;">
You can view the complete ticket history and reply to this message in the Support Center.
</p>
</div>
<div class="footer">
<p style="margin: 0 0 10px 0;">
<strong>GNX Software Solutions</strong><br>
Enterprise Support Center
</p>
<p style="margin: 0; font-size: 12px;">
This is an automated notification. For assistance, visit our <a href="{{ support_url }}" style="color: #daa520;">Support Center</a>
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
NEW RESPONSE ON YOUR TICKET
============================================================
Dear {{ user_name }},
Our support team has responded to your ticket.
Ticket: {{ ticket_number }}
Subject: {{ title }}
------------------------------------------------------------
{{ message_author }} replied:
------------------------------------------------------------
{{ message }}
{{ created_at }}
------------------------------------------------------------
View the full conversation and reply:
{{ support_url }}
============================================================
GNX Software Solutions - Enterprise Support Center
This is an automated notification.
For assistance, visit our Support Center at {{ support_url }}

View File

@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Support Ticket</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333333;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.email-container {
max-width: 700px;
margin: 20px auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.email-header {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: #ffffff;
padding: 30px;
text-align: center;
}
.email-header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.alert-badge {
background-color: rgba(255, 255, 255, 0.2);
padding: 8px 16px;
border-radius: 20px;
display: inline-block;
margin-top: 10px;
font-size: 14px;
}
.email-body {
padding: 40px 30px;
}
.ticket-number-box {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%);
border-left: 4px solid #ef4444;
padding: 20px;
margin: 25px 0;
border-radius: 4px;
}
.ticket-number {
font-size: 28px;
font-weight: 700;
color: #ef4444;
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
.customer-info {
background-color: #f8f9fa;
padding: 20px;
border-radius: 6px;
margin: 25px 0;
}
.info-grid {
display: grid;
grid-template-columns: 140px 1fr;
gap: 12px;
}
.info-label {
font-weight: 600;
color: #555555;
}
.info-value {
color: #333333;
}
.priority-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.priority-high {
background-color: #f59e0b;
color: white;
}
.priority-medium {
background-color: #3b82f6;
color: white;
}
.priority-low {
background-color: #6b7280;
color: white;
}
.priority-critical {
background-color: #ef4444;
color: white;
}
.cta-button {
display: inline-block;
background: linear-gradient(135deg, #0f172a, #1e293b);
color: #ffffff !important;
text-decoration: none;
padding: 14px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
}
.footer {
background-color: #f8f8f8;
padding: 25px 30px;
text-align: center;
font-size: 14px;
color: #666666;
}
.description-box {
background-color: #f8f8f8;
padding: 20px;
border-radius: 6px;
border-left: 3px solid #daa520;
margin: 20px 0;
white-space: pre-wrap;
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="email-header">
<h1>🎫 New Support Ticket</h1>
<div class="alert-badge">Requires Attention</div>
</div>
<!-- Body -->
<div class="email-body">
<p><strong>A new support ticket has been submitted and requires your attention.</strong></p>
<!-- Ticket Number Box -->
<div class="ticket-number-box">
<div style="font-size: 14px; color: #666; margin-bottom: 8px;">Ticket Number</div>
<div class="ticket-number">{{ ticket_number }}</div>
<div style="margin-top: 15px;">
<span class="info-label">Created:</span> {{ created_at }}
</div>
</div>
<!-- Customer Information -->
<div class="customer-info">
<h3 style="margin-top: 0; color: #0f172a;">Customer Information</h3>
<div class="info-grid">
<div class="info-label">Name:</div>
<div class="info-value">{{ user_name }}</div>
<div class="info-label">Email:</div>
<div class="info-value"><a href="mailto:{{ user_email }}" style="color: #3b82f6;">{{ user_email }}</a></div>
{% if user_phone %}
<div class="info-label">Phone:</div>
<div class="info-value"><a href="tel:{{ user_phone }}" style="color: #3b82f6;">{{ user_phone }}</a></div>
{% endif %}
{% if company %}
<div class="info-label">Company:</div>
<div class="info-value">{{ company }}</div>
{% endif %}
</div>
</div>
<!-- Ticket Details -->
<div style="margin: 25px 0;">
<h3 style="color: #0f172a;">Ticket Details</h3>
<div style="margin: 15px 0;">
<div class="info-label" style="display: block; margin-bottom: 8px;">Subject:</div>
<div style="font-size: 18px; font-weight: 600; color: #0f172a;">{{ title }}</div>
</div>
<div class="info-grid" style="margin-top: 20px;">
<div class="info-label">Type:</div>
<div class="info-value">{{ ticket_type }}</div>
<div class="info-label">Category:</div>
<div class="info-value">{{ category }}</div>
<div class="info-label">Priority:</div>
<div>
{% if priority == 'Critical' %}
<span class="priority-badge priority-critical">{{ priority }}</span>
{% elif priority == 'High' %}
<span class="priority-badge priority-high">{{ priority }}</span>
{% elif priority == 'Low' %}
<span class="priority-badge priority-low">{{ priority }}</span>
{% else %}
<span class="priority-badge priority-medium">{{ priority }}</span>
{% endif %}
</div>
<div class="info-label">Status:</div>
<div class="info-value">{{ status }}</div>
</div>
</div>
<!-- Description -->
<div>
<h3 style="color: #0f172a;">Description</h3>
<div class="description-box">{{ description }}</div>
</div>
<!-- Action Buttons -->
<div style="text-align: center; margin: 40px 0 20px;">
<a href="{{ admin_url }}" class="cta-button" style="margin-right: 10px;">View in Admin Panel</a>
<a href="mailto:{{ user_email }}" class="cta-button" style="background: linear-gradient(135deg, #daa520, #d4af37);">Reply to Customer</a>
</div>
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; border-radius: 4px; margin-top: 30px;">
<strong>⚠️ Action Required</strong><br>
Please review and respond to this ticket according to the SLA requirements for {{ priority }} priority tickets.
</div>
</div>
<!-- Footer -->
<div class="footer">
<p style="margin: 0 0 10px 0;">
<strong>GNX Software Solutions</strong><br>
Internal Support Notification
</p>
<p style="margin: 0; font-size: 12px;">
This is an automated notification for new support tickets.<br>
Manage tickets in the <a href="{{ admin_url }}" style="color: #3b82f6;">Admin Panel</a>
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,50 @@
NEW SUPPORT TICKET - REQUIRES ATTENTION
======================================================================
A new support ticket has been submitted and requires your attention.
TICKET NUMBER: {{ ticket_number }}
Created: {{ created_at }}
----------------------------------------------------------------------
CUSTOMER INFORMATION
----------------------------------------------------------------------
Name: {{ user_name }}
Email: {{ user_email }}
{% if user_phone %}Phone: {{ user_phone }}{% endif %}
{% if company %}Company: {{ company }}{% endif %}
----------------------------------------------------------------------
TICKET DETAILS
----------------------------------------------------------------------
Subject: {{ title }}
Type: {{ ticket_type }}
Category: {{ category }}
Priority: {{ priority }}
Status: {{ status }}
----------------------------------------------------------------------
DESCRIPTION
----------------------------------------------------------------------
{{ description }}
----------------------------------------------------------------------
ACTION REQUIRED
----------------------------------------------------------------------
Please review and respond to this ticket according to the SLA requirements for {{ priority }} priority tickets.
View ticket in admin panel:
{{ admin_url }}
Reply to customer:
mailto:{{ user_email }}
======================================================================
GNX Software Solutions - Internal Support Notification
This is an automated notification for new support tickets.

View File

@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ticket Status Updated</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333333;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.email-header {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #ffffff;
padding: 30px;
text-align: center;
}
.email-header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.email-body {
padding: 40px 30px;
}
.status-change-box {
background: #f8f9fa;
border-radius: 8px;
padding: 25px;
margin: 25px 0;
text-align: center;
}
.status-badge {
display: inline-block;
padding: 8px 20px;
border-radius: 20px;
font-size: 14px;
font-weight: 700;
margin: 0 10px;
text-transform: uppercase;
}
.arrow {
font-size: 24px;
color: #666;
margin: 0 10px;
}
.ticket-info {
background: linear-gradient(135deg, rgba(218, 165, 32, 0.1) 0%, rgba(218, 165, 32, 0.05) 100%);
border-left: 4px solid #daa520;
padding: 20px;
margin: 25px 0;
border-radius: 4px;
}
.cta-button {
display: inline-block;
background: linear-gradient(135deg, #daa520, #d4af37);
color: #ffffff !important;
text-decoration: none;
padding: 14px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
}
.footer {
background-color: #f8f8f8;
padding: 25px 30px;
text-align: center;
font-size: 14px;
color: #666666;
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
<h1>🔔 Ticket Status Updated</h1>
</div>
<div class="email-body">
<p>Dear {{ user_name }},</p>
<p>Your support ticket status has been updated.</p>
<div class="ticket-info">
<strong>Ticket:</strong> {{ ticket_number }}<br>
<strong>Subject:</strong> {{ title }}
</div>
<div class="status-change-box">
<h3 style="margin-top: 0;">Status Change</h3>
<div style="display: flex; align-items: center; justify-content: center; flex-wrap: wrap;">
<span class="status-badge" style="background-color: #94a3b8;">{{ old_status }}</span>
<span class="arrow"></span>
<span class="status-badge" style="background-color: {{ status_color }};">{{ new_status }}</span>
</div>
<p style="margin-top: 15px; color: #666; font-size: 14px;">Updated: {{ updated_at }}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ support_url }}" class="cta-button">View Ticket Details</a>
</div>
<p style="color: #666;">
You can check the full details of your ticket and any new messages in the Support Center.
</p>
</div>
<div class="footer">
<p style="margin: 0 0 10px 0;">
<strong>GNX Software Solutions</strong><br>
Enterprise Support Center
</p>
<p style="margin: 0; font-size: 12px;">
This is an automated notification. For assistance, visit our <a href="{{ support_url }}" style="color: #daa520;">Support Center</a>
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
TICKET STATUS UPDATED
============================================================
Dear {{ user_name }},
Your support ticket status has been updated.
Ticket: {{ ticket_number }}
Subject: {{ title }}
------------------------------------------------------------
STATUS CHANGE
------------------------------------------------------------
{{ old_status }} → {{ new_status }}
Updated: {{ updated_at }}
------------------------------------------------------------
You can check the full details of your ticket and any new messages in the Support Center:
{{ support_url }}
============================================================
GNX Software Solutions - Enterprise Support Center
This is an automated notification.
For assistance, visit our Support Center at {{ support_url }}

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,17 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'tickets', views.SupportTicketViewSet, basename='ticket')
router.register(r'categories', views.TicketCategoryViewSet, basename='category')
router.register(r'statuses', views.TicketStatusViewSet, basename='status')
router.register(r'priorities', views.TicketPriorityViewSet, basename='priority')
router.register(r'knowledge-base-categories', views.KnowledgeBaseCategoryViewSet, basename='kb-category')
router.register(r'knowledge-base', views.KnowledgeBaseArticleViewSet, basename='kb-article')
router.register(r'settings', views.SupportSettingsViewSet, basename='settings')
urlpatterns = [
path('', include(router.urls)),
]

View File

@@ -0,0 +1,216 @@
from rest_framework import viewsets, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from django.shortcuts import get_object_or_404
from django.db.models import Q
from django.utils import timezone
from .models import (
SupportTicket, TicketStatus, TicketPriority, TicketCategory,
TicketMessage, KnowledgeBaseCategory, KnowledgeBaseArticle, SupportSettings
)
from .serializers import (
SupportTicketSerializer, SupportTicketCreateSerializer,
TicketStatusSerializer, TicketPrioritySerializer, TicketCategorySerializer,
TicketMessageSerializer, TicketStatusCheckSerializer,
KnowledgeBaseCategorySerializer, KnowledgeBaseArticleListSerializer,
KnowledgeBaseArticleDetailSerializer, SupportSettingsSerializer
)
class SupportTicketViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing support tickets.
Public endpoint for creating tickets and checking status.
"""
queryset = SupportTicket.objects.all()
permission_classes = [AllowAny]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ['ticket_number', 'title', 'user_email', 'user_name']
ordering_fields = ['created_at', 'updated_at', 'priority']
ordering = ['-created_at']
def get_serializer_class(self):
if self.action == 'create':
return SupportTicketCreateSerializer
return SupportTicketSerializer
@action(detail=False, methods=['post'], url_path='check-status')
def check_status(self, request):
"""
Check the status of a ticket by ticket number.
POST /api/support/tickets/check-status/
Body: {"ticket_number": "TKT-20231015-XXXXX"}
"""
serializer = TicketStatusCheckSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
ticket_number = serializer.validated_data['ticket_number']
try:
ticket = SupportTicket.objects.get(ticket_number=ticket_number)
ticket_serializer = SupportTicketSerializer(ticket)
return Response(ticket_serializer.data)
except SupportTicket.DoesNotExist:
return Response(
{'error': 'Ticket not found. Please check your ticket number.'},
status=status.HTTP_404_NOT_FOUND
)
@action(detail=True, methods=['post'], url_path='add-message')
def add_message(self, request, pk=None):
"""
Add a message to a ticket.
POST /api/support/tickets/{id}/add-message/
Body: {
"content": "Message content",
"author_name": "User Name",
"author_email": "user@example.com"
}
"""
ticket = self.get_object()
message_data = {
'ticket': ticket.id,
'content': request.data.get('content'),
'author_name': request.data.get('author_name', ticket.user_name),
'author_email': request.data.get('author_email', ticket.user_email),
'message_type': 'user_message'
}
serializer = TicketMessageSerializer(data=message_data)
serializer.is_valid(raise_exception=True)
serializer.save()
# Update ticket's last activity
ticket.last_activity = timezone.now()
ticket.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
class TicketCategoryViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for ticket categories.
Public read-only access to categories.
"""
queryset = TicketCategory.objects.filter(is_active=True)
serializer_class = TicketCategorySerializer
permission_classes = [AllowAny]
class TicketStatusViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for ticket statuses.
Public read-only access to statuses.
"""
queryset = TicketStatus.objects.filter(is_active=True)
serializer_class = TicketStatusSerializer
permission_classes = [AllowAny]
class TicketPriorityViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for ticket priorities.
Public read-only access to priorities.
"""
queryset = TicketPriority.objects.filter(is_active=True)
serializer_class = TicketPrioritySerializer
permission_classes = [AllowAny]
class KnowledgeBaseCategoryViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for knowledge base categories.
Public read-only access.
"""
queryset = KnowledgeBaseCategory.objects.filter(is_active=True)
serializer_class = KnowledgeBaseCategorySerializer
permission_classes = [AllowAny]
lookup_field = 'slug'
class KnowledgeBaseArticleViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for knowledge base articles.
Public read-only access to published articles.
"""
queryset = KnowledgeBaseArticle.objects.filter(is_published=True)
permission_classes = [AllowAny]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ['title', 'content', 'summary', 'keywords']
ordering_fields = ['created_at', 'view_count', 'helpful_count']
ordering = ['-created_at']
lookup_field = 'slug'
def get_serializer_class(self):
if self.action == 'retrieve':
return KnowledgeBaseArticleDetailSerializer
return KnowledgeBaseArticleListSerializer
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
# Increment view count
instance.view_count += 1
instance.save(update_fields=['view_count'])
serializer = self.get_serializer(instance)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='mark-helpful')
def mark_helpful(self, request, slug=None):
"""
Mark an article as helpful.
POST /api/support/knowledge-base/{slug}/mark-helpful/
Body: {"helpful": true/false}
"""
article = self.get_object()
is_helpful = request.data.get('helpful', True)
if is_helpful:
article.helpful_count += 1
else:
article.not_helpful_count += 1
article.save()
return Response({
'helpful_count': article.helpful_count,
'not_helpful_count': article.not_helpful_count
})
@action(detail=False, methods=['get'], url_path='featured')
def featured(self, request):
"""
Get featured articles.
GET /api/support/knowledge-base/featured/
"""
featured_articles = self.queryset.filter(is_featured=True)[:6]
serializer = self.get_serializer(featured_articles, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'], url_path='by-category/(?P<category_slug>[^/.]+)')
def by_category(self, request, category_slug=None):
"""
Get articles by category slug.
GET /api/support/knowledge-base/by-category/{category_slug}/
"""
articles = self.queryset.filter(category__slug=category_slug)
page = self.paginate_queryset(articles)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(articles, many=True)
return Response(serializer.data)
class SupportSettingsViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for support settings.
Public read-only access to active settings.
"""
queryset = SupportSettings.objects.filter(is_active=True)
serializer_class = SupportSettingsSerializer
permission_classes = [AllowAny]
lookup_field = 'setting_name'