base setup
This commit is contained in:
0
at_django_boilerplate/backend_admin/__init__.py
Executable file
0
at_django_boilerplate/backend_admin/__init__.py
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
5
at_django_boilerplate/backend_admin/admin.py
Executable file
5
at_django_boilerplate/backend_admin/admin.py
Executable file
@@ -0,0 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import SEOConfiguration
|
||||
# Register your models here.
|
||||
|
||||
admin.site.register(SEOConfiguration)
|
||||
6
at_django_boilerplate/backend_admin/apps.py
Executable file
6
at_django_boilerplate/backend_admin/apps.py
Executable file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BackendAdminConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'at_django_boilerplate.backend_admin'
|
||||
38
at_django_boilerplate/backend_admin/forms.py
Executable file
38
at_django_boilerplate/backend_admin/forms.py
Executable file
@@ -0,0 +1,38 @@
|
||||
from django import forms
|
||||
from .models import SEOConfiguration
|
||||
|
||||
|
||||
class SEOConfigurationForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = SEOConfiguration
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
# widgets = {
|
||||
# "home_page_meta_title": forms.Text(attrs={"class": "form-control", "placeholder": "Home Page Meta Title"}),
|
||||
# "home_page_meta_description": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Home Page Meta Description"}),
|
||||
|
||||
# "about_meta_title": forms.TextInput(attrs={"class": "form-control", "placeholder": "About Page Meta Title"}),
|
||||
# "about_meta_description": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "About Page Meta Description"}),
|
||||
|
||||
|
||||
# "tools_meta_title": forms.TextInput(attrs={"class": "form-control", "placeholder": "Tools Page Meta Title"}),
|
||||
# "tools_meta_description": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Tools Page Meta Description"}),
|
||||
|
||||
# "contact_meta_title": forms.TextInput(attrs={"class": "form-control", "placeholder": "Contact Page Meta Title"}),
|
||||
# "contact_meta_description": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Contact Page Meta Description"}),
|
||||
|
||||
# "career_meta_title": forms.TextInput(attrs={"class": "form-control", "placeholder": "Career Page Meta Title"}),
|
||||
# "career_meta_description": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Career Page Meta Description"}),
|
||||
|
||||
# "blog_meta_title": forms.TextInput(attrs={"class": "form-control", "placeholder": "Blog Page Meta Title"}),
|
||||
# "blog_meta_description": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Blog Page Meta Description"}),
|
||||
|
||||
# "term_and_con_meta_title": forms.TextInput(attrs={"class": "form-control", "placeholder": "Terms & Conditions Meta Title"}),
|
||||
# "term_and_con_meta_description": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Terms & Conditions Meta Description"}),
|
||||
# "term_and_con_canonical_url": forms.URLInput(attrs={"class": "form-control", "placeholder": "Canonical URL"}),
|
||||
|
||||
# "privacy_pol_meta_title": forms.TextInput(attrs={"class": "form-control", "placeholder": "Privacy Policy Meta Title"}),
|
||||
# "privacy_pol_meta_description": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Privacy Policy Meta Description"}),
|
||||
# "privacy_pol_canonical_url": forms.URLInput(attrs={"class": "form-control", "placeholder": "Canonical URL"}),
|
||||
# }
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.6 on 2025-12-29 05:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SEOConfiguration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('home_page_meta_title', models.CharField(help_text='SEO title for the page (max 60 characters).', max_length=100)),
|
||||
('home_page_meta_description', models.CharField(help_text='Meta description for search engines (max 160 characters).', max_length=300)),
|
||||
('about_meta_title', models.CharField(help_text='SEO title for the page (max 60 characters).', max_length=100)),
|
||||
('about_meta_description', models.CharField(help_text='Meta description for search engines (max 160 characters).', max_length=300)),
|
||||
('tools_meta_title', models.CharField(help_text='SEO title for the page (max 60 characters).', max_length=100)),
|
||||
('tools_meta_description', models.CharField(help_text='Meta description for search engines (max 160 characters).', max_length=300)),
|
||||
('contact_meta_title', models.CharField(help_text='SEO title for the page (max 60 characters).', max_length=100)),
|
||||
('contact_meta_description', models.CharField(help_text='Meta description for search engines (max 160 characters).', max_length=300)),
|
||||
('career_meta_title', models.CharField(help_text='SEO title for the page (max 60 characters).', max_length=100)),
|
||||
('career_meta_description', models.CharField(help_text='Meta description for search engines (max 160 characters).', max_length=300)),
|
||||
('blog_meta_title', models.CharField(help_text='SEO title for the page (max 60 characters).', max_length=100)),
|
||||
('blog_meta_description', models.CharField(help_text='Meta description for search engines (max 160 characters).', max_length=300)),
|
||||
('term_and_con_meta_title', models.CharField(help_text='SEO title for the page (max 60 characters).', max_length=100)),
|
||||
('term_and_con_meta_description', models.CharField(help_text='Meta description for search engines (max 160 characters).', max_length=300)),
|
||||
('term_and_con_canonical_url', models.URLField(blank=True, help_text='Canonical URL to avoid duplicate content.', null=True)),
|
||||
('privacy_pol_meta_title', models.CharField(help_text='SEO title for the page (max 60 characters).', max_length=100)),
|
||||
('privacy_pol_meta_description', models.CharField(help_text='Meta description for search engines (max 160 characters).', max_length=300)),
|
||||
('privacy_pol_canonical_url', models.URLField(blank=True, help_text='Canonical URL to avoid duplicate content.', null=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
at_django_boilerplate/backend_admin/migrations/__init__.py
Executable file
0
at_django_boilerplate/backend_admin/migrations/__init__.py
Executable file
Binary file not shown.
Binary file not shown.
30
at_django_boilerplate/backend_admin/models.py
Executable file
30
at_django_boilerplate/backend_admin/models.py
Executable file
@@ -0,0 +1,30 @@
|
||||
from django.db import models
|
||||
|
||||
class SEOConfiguration(models.Model):
|
||||
|
||||
|
||||
home_page_meta_title = models.CharField(max_length=100, help_text="SEO title for the page (max 60 characters).")
|
||||
home_page_meta_description = models.CharField(max_length=300, help_text="Meta description for search engines (max 160 characters).")
|
||||
|
||||
about_meta_title = models.CharField(max_length=100, help_text="SEO title for the page (max 60 characters).")
|
||||
about_meta_description = models.CharField(max_length=300, help_text="Meta description for search engines (max 160 characters).")
|
||||
|
||||
tools_meta_title = models.CharField(max_length=100, help_text="SEO title for the page (max 60 characters).")
|
||||
tools_meta_description = models.CharField(max_length=300, help_text="Meta description for search engines (max 160 characters).")
|
||||
|
||||
contact_meta_title = models.CharField(max_length=100, help_text="SEO title for the page (max 60 characters).")
|
||||
contact_meta_description = models.CharField(max_length=300, help_text="Meta description for search engines (max 160 characters).")
|
||||
|
||||
career_meta_title = models.CharField(max_length=100, help_text="SEO title for the page (max 60 characters).")
|
||||
career_meta_description = models.CharField(max_length=300, help_text="Meta description for search engines (max 160 characters)." )
|
||||
|
||||
blog_meta_title = models.CharField(max_length=100, help_text="SEO title for the page (max 60 characters).")
|
||||
blog_meta_description = models.CharField(max_length=300, help_text="Meta description for search engines (max 160 characters).")
|
||||
|
||||
term_and_con_meta_title = models.CharField(max_length=100, help_text="SEO title for the page (max 60 characters).")
|
||||
term_and_con_meta_description = models.CharField(max_length=300, help_text="Meta description for search engines (max 160 characters).")
|
||||
term_and_con_canonical_url = models.URLField(blank=True, null=True, help_text="Canonical URL to avoid duplicate content.")
|
||||
|
||||
privacy_pol_meta_title = models.CharField(max_length=100, help_text="SEO title for the page (max 60 characters).")
|
||||
privacy_pol_meta_description = models.CharField(max_length=300, help_text="Meta description for search engines (max 160 characters).")
|
||||
privacy_pol_canonical_url = models.URLField(blank=True, null=True, help_text="Canonical URL to avoid duplicate content.")
|
||||
25
at_django_boilerplate/backend_admin/templates/admin_dashboard.html
Executable file
25
at_django_boilerplate/backend_admin/templates/admin_dashboard.html
Executable file
@@ -0,0 +1,25 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<h2 class="text-2xl font-bold mb-6 text-gray-800">Admin Dashboard</h2>
|
||||
|
||||
<div class="flex gap-5">
|
||||
|
||||
<a href="{% url 'contact_list' %}"
|
||||
class="px-6 py-3 border-2 border-blue-600 rounded-xl font-semibold text-blue-600 hover:text-blue-800 transition-colors">
|
||||
View Contacts
|
||||
</a>
|
||||
|
||||
<a href="{% url 'subscriber_list' %}"
|
||||
class="px-6 py-3 border-2 border-green-600 rounded-xl font-semibold text-green-600 hover:text-green-800 transition-colors">
|
||||
View Subscribers
|
||||
</a>
|
||||
<a href="{% url 'user_activity' %}"
|
||||
class="px-6 py-3 border-2 border-green-600 rounded-xl font-semibold text-green-600 hover:text-green-800 transition-colors">
|
||||
View Activiy
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
188
at_django_boilerplate/backend_admin/templates/appointment_list.html
Executable file
188
at_django_boilerplate/backend_admin/templates/appointment_list.html
Executable file
@@ -0,0 +1,188 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
{% block title %}Appointment Leads - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto mt-10 px-4 max-w-7xl">
|
||||
<div class="bg-white shadow-lg rounded-2xl overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="px-8 py-6 border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-purple-50">
|
||||
<h2 class="text-3xl font-bold text-gray-800">Appointment Requests</h2>
|
||||
<p class="text-gray-600 mt-2">Manage appointments: update status, reschedule, and track progress</p>
|
||||
</div>
|
||||
|
||||
{% if appointments %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
|
||||
<!-- Table Head -->
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ticket ID</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Contact</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Expert</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Scheduled For</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for apt in appointments %}
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">{{ forloop.counter }}</td>
|
||||
|
||||
<td class="px-6 py-4 whitespace-nowrap font-medium text-indigo-600">
|
||||
APP-{{ apt.pk|stringformat:"06d" }}
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 whitespace-nowrap font-medium text-gray-900">
|
||||
{{ apt.full_name }}
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 text-sm text-gray-600">
|
||||
{{ apt.email }}<br>
|
||||
<small class="text-gray-500">{{ apt.phone }}</small>
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{{ apt.get_meeting_with_display }}
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 text-sm">
|
||||
{{ apt.appointment_datetime|date:"d M Y" }} at {{ apt.appointment_datetime|time:"h:i A" }}
|
||||
</td>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-3 py-1 inline-flex text-xs font-semibold rounded-full
|
||||
{% if apt.status == 'pending' %}bg-yellow-100 text-yellow-800
|
||||
{% elif apt.status == 'contacted' %}bg-blue-100 text-blue-800
|
||||
{% elif apt.status == 'in_process' %}bg-purple-100 text-purple-800
|
||||
{% elif apt.status == 'rescheduled' %}bg-indigo-100 text-indigo-800
|
||||
{% elif apt.status == 'completed' %}bg-green-100 text-green-800
|
||||
{% elif apt.status == 'cancelled' %}bg-red-100 text-red-800
|
||||
{% else %}bg-gray-100 text-gray-800{% endif %}">
|
||||
{{ apt.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<button onclick="openModal('modal-{{ apt.id }}')"
|
||||
class="bg-gradient-to-r from-indigo-600 to-purple-600 text-white px-4 py-2 rounded-lg text-xs font-medium hover:shadow-lg transition">
|
||||
Manage
|
||||
</button>
|
||||
|
||||
<!-- MODAL -->
|
||||
<div id="modal-{{ apt.id }}" class="fixed inset-0 bg-black bg-opacity-60 hidden flex items-center justify-center z-50">
|
||||
<div class="bg-white w-full max-w-lg rounded-2xl shadow-2xl p-8 relative animate-fadeIn">
|
||||
|
||||
<button onclick="closeModal('modal-{{ apt.id }}')"
|
||||
class="absolute top-4 right-6 text-gray-400 hover:text-gray-600 text-3xl">×</button>
|
||||
|
||||
<h3 class="text-2xl font-bold mb-6 text-gray-800">
|
||||
Manage Appointment – {{ apt.full_name }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 mb-6">Ticket: APP-{{ apt.pk|stringformat:"06d" }}</p>
|
||||
|
||||
<!-- Update Status -->
|
||||
<form method="post" class="mb-8">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="appointment_id" value="{{ apt.id }}">
|
||||
<input type="hidden" name="action" value="update_status">
|
||||
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">Update Status</label>
|
||||
<select name="status" class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
|
||||
{% for value, label in status_choices %}
|
||||
<option value="{{ value }}" {% if apt.status == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<button type="submit" class="mt-4 w-full bg-indigo-600 text-white py-3 rounded-lg font-medium hover:bg-indigo-700 transition">
|
||||
Update Status
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Reschedule -->
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="appointment_id" value="{{ apt.id }}">
|
||||
<input type="hidden" name="action" value="reschedule">
|
||||
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">Reschedule Appointment</label>
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<input type="date" name="new_date"
|
||||
value="{{ apt.appointment_datetime|date:'Y-m-d' }}"
|
||||
class="border border-gray-300 rounded-lg px-4 py-3" required>
|
||||
<input type="time" name="new_time"
|
||||
value="{{ apt.appointment_datetime|time:'H:i' }}"
|
||||
class="border border-gray-300 rounded-lg px-4 py-3" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full bg-green-600 text-white py-3 rounded-lg font-medium hover:bg-green-700 transition">
|
||||
Reschedule Appointment
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Notes (Read-only) -->
|
||||
{% if apt.notes %}
|
||||
<div class="mt-8 p-4 bg-gray-50 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-700">User Notes:</p>
|
||||
<p class="text-sm text-gray-600 mt-1">{{ apt.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="p-16 text-center">
|
||||
<p class="text-xl text-gray-500">No appointment requests yet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Scripts -->
|
||||
<script>
|
||||
function openModal(id) {
|
||||
document.getElementById(id).classList.remove('hidden');
|
||||
document.getElementById(id).classList.add('flex');
|
||||
}
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.add('hidden');
|
||||
document.getElementById(id).classList.remove('flex');
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.addEventListener('click', function(e) {
|
||||
document.querySelectorAll('[id^="modal-"]').forEach(modal => {
|
||||
if (e.target === modal) {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fadeIn { animation: fadeIn 0.3s ease-out; }
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
239
at_django_boilerplate/backend_admin/templates/contact_list.html
Executable file
239
at_django_boilerplate/backend_admin/templates/contact_list.html
Executable file
@@ -0,0 +1,239 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- Back -->
|
||||
<div class="mb-6">
|
||||
<a href="{% url 'admin_dashboard' %}"
|
||||
class="inline-block px-4 py-2 border-2 border-gray-800 rounded-xl font-bold text-gray-800 hover:text-gray-900 hover:border-gray-900">
|
||||
← Admin Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-8">
|
||||
|
||||
<h1 class="text-3xl font-bold mb-6 text-gray-800 text-center">All Contacts</h1>
|
||||
|
||||
<!-- ================= FILTER ================= -->
|
||||
<!-- ================= FILTER ================= -->
|
||||
<form method="get" class="mb-6 flex flex-col sm:flex-row gap-3">
|
||||
|
||||
<!-- Search -->
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value="{{ request.GET.search }}"
|
||||
placeholder="Search by name, email, or message..."
|
||||
class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring"
|
||||
>
|
||||
|
||||
<!-- Junk Filter -->
|
||||
<select
|
||||
name="status"
|
||||
class="px-4 py-2 border rounded-lg focus:outline-none focus:ring">
|
||||
|
||||
<option value="">All Messages</option>
|
||||
<option value="inbox"
|
||||
{% if request.GET.status == "inbox" %}selected{% endif %}>
|
||||
Inbox
|
||||
</option>
|
||||
<option value="junk"
|
||||
{% if request.GET.status == "junk" %}selected{% endif %}>
|
||||
Junk
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<button class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
|
||||
Apply
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
<!-- ================= TABLE ================= -->
|
||||
<div class="overflow-x-auto shadow-lg rounded-lg">
|
||||
<table class="min-w-[700px] w-full table-auto bg-white divide-y divide-gray-200">
|
||||
|
||||
<thead class="bg-indigo-600">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-white uppercase">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-white uppercase">Email</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-white uppercase">Message</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-white uppercase">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-white uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
|
||||
{% for c in contacts %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
|
||||
<td class="px-6 py-4 font-medium">
|
||||
{{ c.name }}
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 text-gray-600">
|
||||
{{ c.email }}
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 max-w-xs truncate text-gray-700">
|
||||
{{ c.message }}
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 text-gray-500 whitespace-nowrap">
|
||||
{{ c.created_at|date:"M d, Y H:i" }}
|
||||
</td>
|
||||
|
||||
<!-- ================= ACTIONS ================= -->
|
||||
<td class="px-6 py-4 flex gap-3">
|
||||
|
||||
<!-- View -->
|
||||
<button
|
||||
onclick="openModal(
|
||||
'{{ c.name }}',
|
||||
'{{ c.email }}',
|
||||
'{{ c.message|escapejs }}',
|
||||
'{{ c.created_at|date:"M d, Y H:i" }}'
|
||||
)"
|
||||
class="text-indigo-600 hover:text-indigo-800"
|
||||
title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
|
||||
<!-- Junk -->
|
||||
{% if not c.is_junk %}
|
||||
<form method="post"
|
||||
action="{% url 'set_message_to_junk' c.id %}"
|
||||
onsubmit="return confirmJunk('{{ c.email }}', '{{ c.phone|default:'' }}')">
|
||||
{% csrf_token %}
|
||||
<button
|
||||
type="submit"
|
||||
class="text-red-600 hover:text-red-800"
|
||||
title="Mark as junk">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-400">Junk</span>
|
||||
{% endif %}
|
||||
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-10 text-gray-400">
|
||||
No contacts found.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ================= PAGINATION ================= -->
|
||||
{% if is_paginated %}
|
||||
<div class="mt-6 flex justify-center items-center gap-2">
|
||||
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}&search={{ request.GET.search }}&status={{ request.GET.status }}"
|
||||
|
||||
class="px-3 py-1 border rounded hover:bg-gray-100">
|
||||
Prev
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-3 py-1 bg-indigo-600 text-white rounded">
|
||||
{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}&search={{ request.GET.search }}&status={{ request.GET.status }}"
|
||||
|
||||
class="px-3 py-1 border rounded hover:bg-gray-100">
|
||||
Next
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ================= MODAL ================= -->
|
||||
|
||||
<div id="contactModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 hidden flex items-center justify-center z-50">
|
||||
|
||||
<div class="bg-white rounded-xl shadow-2xl max-w-lg w-full p-6 relative">
|
||||
|
||||
<button onclick="closeModal()"
|
||||
class="absolute top-3 right-3 text-gray-400 hover:text-gray-700 text-2xl">
|
||||
×
|
||||
</button>
|
||||
|
||||
<h2 class="text-2xl font-bold mb-4 text-gray-800">Contact Details</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
|
||||
<p><strong>Name:</strong> <span id="modalName"></span></p>
|
||||
<p><strong>Email:</strong> <span id="modalEmail"></span></p>
|
||||
<p><strong>Date:</strong> <span id="modalDate"></span></p>
|
||||
|
||||
<div>
|
||||
<p class="font-semibold mb-1">Message</p>
|
||||
<div id="modalMessage"
|
||||
class="bg-gray-100 p-3 rounded-lg text-sm whitespace-pre-wrap">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-right">
|
||||
<button onclick="closeModal()"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= JS ================= -->
|
||||
|
||||
<script>
|
||||
function confirmJunk(email, phone) {
|
||||
let message = "Set ALL messages";
|
||||
|
||||
if (email) {
|
||||
message += " from email: " + email;
|
||||
}
|
||||
|
||||
if (phone) {
|
||||
message += email ? " and phone: " + phone : " from phone: " + phone;
|
||||
}
|
||||
|
||||
message += " to junk?\n\nThis will also add the user to the spam list.";
|
||||
|
||||
return confirm(message);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<script>
|
||||
function openModal(name, email, message, date) {
|
||||
document.getElementById("modalName").innerText = name;
|
||||
document.getElementById("modalEmail").innerText = email;
|
||||
document.getElementById("modalMessage").innerText = message;
|
||||
document.getElementById("modalDate").innerText = date;
|
||||
document.getElementById("contactModal").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById("contactModal").classList.add("hidden");
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
22
at_django_boilerplate/backend_admin/templates/seo_config.html
Executable file
22
at_django_boilerplate/backend_admin/templates/seo_config.html
Executable file
@@ -0,0 +1,22 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
<h2 class="mb-4">SEO Configuration</h2>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="card p-4 shadow-sm">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
60
at_django_boilerplate/backend_admin/templates/subscriber.html
Executable file
60
at_django_boilerplate/backend_admin/templates/subscriber.html
Executable file
@@ -0,0 +1,60 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<div class="mb-6">
|
||||
<a href="{% url 'admin_dashboard' %}"
|
||||
class="inline-block px-4 py-2 border-2 border-gray-800 rounded-xl font-bold text-gray-800 hover:text-gray-900 hover:border-gray-900 transition-colors">
|
||||
← Admin Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-8">
|
||||
|
||||
|
||||
<h2 class="text-3xl font-bold mb-6 text-gray-800 text-center">Subscriber List</h2>
|
||||
|
||||
<div class="overflow-x-auto shadow-lg rounded-lg">
|
||||
<table class="min-w-full bg-white divide-y divide-gray-200">
|
||||
<thead class="bg-indigo-600">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Email</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Subscribed At</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for subscriber in subscribers %}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-800 font-medium">{{ subscriber.id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-700">{{ subscriber.email }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-600">{{ subscriber.subscribed_at|date:"M d, Y H:i" }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if subscriber.is_active %}
|
||||
<span class="text-green-600 font-semibold">✅ Active</span>
|
||||
{% else %}
|
||||
<span class="text-red-600 font-semibold">❌ Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<a href="{% url 'toggle_subscriber' subscriber.id %}"
|
||||
class="text-indigo-600 hover:text-indigo-800 font-medium">
|
||||
{% if subscriber.is_active %}Deactivate{% else %}Activate{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-8 text-gray-400">No subscribers yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
3
at_django_boilerplate/backend_admin/tests.py
Executable file
3
at_django_boilerplate/backend_admin/tests.py
Executable file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
21
at_django_boilerplate/backend_admin/urls.py
Executable file
21
at_django_boilerplate/backend_admin/urls.py
Executable file
@@ -0,0 +1,21 @@
|
||||
# urls.py
|
||||
from django.urls import path
|
||||
# from .views import (AdminBusinessListView,
|
||||
# admin_add_business_ajax)
|
||||
from . import views
|
||||
urlpatterns = [
|
||||
|
||||
# path('admin/businesses/', AdminBusinessListView.as_view(), name='admin-business-list'),
|
||||
# path('admin/business/add/', admin_add_business_ajax, name='admin_add_business_ajax'),
|
||||
path("admin_view", views.admin_dashboard, name="admin_dashboard"), # default dashboard
|
||||
path('admin_view/contact_list/', views.ContactListView.as_view(), name='contact_list'),
|
||||
path("admin_view/contacts/<uuid:pk>/junk/", views.set_message_to_junk, name="set_message_to_junk"),
|
||||
path('admin_view/subscriber_list/', views.subscriber_list, name='subscriber_list'),
|
||||
path('admin_view/subscriber_toggle/<uuid:pk>/', views.toggle_subscriber, name='toggle_subscriber'),
|
||||
|
||||
path('appointments/', views.appointment_list, name='appointment_list'),
|
||||
|
||||
|
||||
path("seo/config/", views.SEOConfigView.as_view(), name="seo_config"),
|
||||
|
||||
]
|
||||
49
at_django_boilerplate/backend_admin/utils.py
Executable file
49
at_django_boilerplate/backend_admin/utils.py
Executable file
@@ -0,0 +1,49 @@
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
# from business.models import Subscription, SubscriptionPackage
|
||||
|
||||
|
||||
# def assign_free_trial_subscription(business):
|
||||
# """
|
||||
# Assigns a free trial subscription to a business, if allowed.
|
||||
# Handles expiration of old trials and avoids duplicates.
|
||||
# """
|
||||
# try:
|
||||
# # Find the first free trial plan
|
||||
# plan = SubscriptionPackage.objects.filter(
|
||||
# provider='trial',
|
||||
# is_free_plan=True,
|
||||
# free_trial_days__gt=0
|
||||
# ).first()
|
||||
|
||||
# if not plan:
|
||||
# return {"success": False, "message": "No free trial plan available."}
|
||||
|
||||
# # Check for existing active trial
|
||||
# existing_trial = Subscription.objects.filter(
|
||||
# business=business,
|
||||
# plan__provider='trial',
|
||||
# status='trial'
|
||||
# ).order_by('-subscribed_on').first()
|
||||
|
||||
# if existing_trial and existing_trial.current_period_end > timezone.now():
|
||||
# return {"success": False, "message": "Active trial already exists."}
|
||||
|
||||
# # Mark existing trial as expired (if any)
|
||||
# if existing_trial:
|
||||
# existing_trial.status = 'expired'
|
||||
# existing_trial.save()
|
||||
|
||||
# # Create new free trial
|
||||
# Subscription.objects.create(
|
||||
# business=business,
|
||||
# plan=plan,
|
||||
# status='trial',
|
||||
# subscribed_on=timezone.now(),
|
||||
# current_period_end=timezone.now() + timedelta(days=plan.free_trial_days)
|
||||
# )
|
||||
|
||||
# return {"success": True, "message": f"{plan.free_trial_days}-day free trial assigned."}
|
||||
|
||||
# except Exception as e:
|
||||
# return {"success": False, "message": f"Error assigning trial: {str(e)}"}
|
||||
156
at_django_boilerplate/backend_admin/views.py
Executable file
156
at_django_boilerplate/backend_admin/views.py
Executable file
@@ -0,0 +1,156 @@
|
||||
from at_django_boilerplate.communications.models import FeedbackModel,AppointmentModel
|
||||
from at_django_boilerplate.core.models import Subscriber
|
||||
from django.shortcuts import render, redirect,get_object_or_404
|
||||
from django.contrib import messages
|
||||
from django.views import View
|
||||
from .models import SEOConfiguration
|
||||
from .forms import SEOConfigurationForm
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.generic import ListView
|
||||
from django.db.models import Q
|
||||
from .utils import *
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.http import HttpResponseForbidden
|
||||
|
||||
# Keep your custom decorator
|
||||
def superuser_required(view_func):
|
||||
def wrapper(request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
if not request.user.is_superuser:
|
||||
return HttpResponseForbidden()
|
||||
return view_func(request, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
|
||||
class SuperuserRequiredMixin(UserPassesTestMixin):
|
||||
def test_func(self):
|
||||
return self.request.user.is_superuser
|
||||
User = get_user_model()
|
||||
|
||||
@superuser_required
|
||||
def admin_dashboard(request):
|
||||
return render(request, "admin_dashboard.html")
|
||||
|
||||
|
||||
|
||||
class ContactListView(SuperuserRequiredMixin ,ListView):
|
||||
model = FeedbackModel
|
||||
template_name = 'contact_list.html'
|
||||
context_object_name = 'contacts'
|
||||
paginate_by = 50
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = FeedbackModel.objects.all().order_by("-created_at")
|
||||
|
||||
search = self.request.GET.get("search")
|
||||
status = self.request.GET.get("status") # NEW
|
||||
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=search) |
|
||||
Q(email__icontains=search) |
|
||||
Q(message__icontains=search)
|
||||
)
|
||||
|
||||
# ================= JUNK FILTER =================
|
||||
if status == "junk":
|
||||
queryset = queryset.filter(is_junk=True)
|
||||
elif status == "inbox":
|
||||
queryset = queryset.filter(is_junk=False)
|
||||
|
||||
return queryset
|
||||
|
||||
@superuser_required
|
||||
def set_message_to_junk(request, pk): # Change parameter from 'uuid' to 'pk'
|
||||
contact = get_object_or_404(FeedbackModel, pk=pk) # Use 'id=pk' instead of 'uuid=uuid'
|
||||
contact.is_junk = True
|
||||
contact.save(update_fields=["is_junk"])
|
||||
|
||||
messages.success(request, "Message marked as junk.")
|
||||
return redirect(request.META.get("HTTP_REFERER", "contact_list")) # Better fallback
|
||||
|
||||
@superuser_required
|
||||
# Show all subscribers
|
||||
def subscriber_list(request):
|
||||
subscribers = Subscriber.objects.all().order_by("-subscribed_at")
|
||||
return render(request, "subscriber.html", {"subscribers": subscribers})
|
||||
|
||||
|
||||
|
||||
@superuser_required
|
||||
def toggle_subscriber(request, pk): # Changed from pk to uuid
|
||||
subscriber = get_object_or_404(Subscriber, pk=pk) # Assuming Subscriber also uses UUID as PK
|
||||
subscriber.is_active = not subscriber.is_active
|
||||
subscriber.save()
|
||||
return redirect("subscriber_list")
|
||||
|
||||
class SEOConfigView(View):
|
||||
template_name = "seo_config.html"
|
||||
|
||||
def get(self, request):
|
||||
seo_config = SEOConfiguration.objects.first()
|
||||
if not seo_config:
|
||||
seo_config = SEOConfiguration.objects.create() # ensure one record exists
|
||||
|
||||
form = SEOConfigurationForm(instance=seo_config)
|
||||
return render(request, self.template_name, {"form": form})
|
||||
|
||||
def post(self, request):
|
||||
seo_config = SEOConfiguration.objects.first()
|
||||
form = SEOConfigurationForm(request.POST, instance=seo_config)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, "SEO configuration updated successfully.")
|
||||
return redirect("seo_config")
|
||||
return render(request, self.template_name, {"form": form})
|
||||
|
||||
|
||||
@superuser_required
|
||||
def appointment_list(request):
|
||||
if request.method == "POST":
|
||||
appointment_id = request.POST.get("appointment_id")
|
||||
action = request.POST.get("action")
|
||||
appointment = get_object_or_404(AppointmentModel, id=appointment_id)
|
||||
|
||||
if action == "update_status":
|
||||
new_status = request.POST.get("status")
|
||||
if new_status in dict(AppointmentModel.STATUS_CHOICES):
|
||||
appointment.status = new_status
|
||||
appointment.save()
|
||||
messages.success(request, f"Status updated to '{appointment.get_status_display()}' for {appointment.full_name}")
|
||||
|
||||
elif action == "reschedule":
|
||||
date_str = request.POST.get("new_date")
|
||||
time_str = request.POST.get("new_time")
|
||||
if date_str and time_str:
|
||||
try:
|
||||
new_datetime_str = f"{date_str} {time_str}"
|
||||
new_datetime = timezone.datetime.strptime(new_datetime_str, "%Y-%m-%d %H:%M")
|
||||
new_datetime = timezone.make_aware(new_datetime)
|
||||
|
||||
if new_datetime > timezone.now():
|
||||
appointment.appointment_datetime = new_datetime
|
||||
appointment.status = 'rescheduled'
|
||||
appointment.save()
|
||||
messages.success(request, f"Appointment rescheduled to {new_datetime.strftime('%d %B %Y, %I:%M %p')}")
|
||||
else:
|
||||
messages.error(request, "New time must be in the future.")
|
||||
except ValueError:
|
||||
messages.error(request, "Invalid date/time format.")
|
||||
else:
|
||||
messages.error(request, "Please select both date and time.")
|
||||
|
||||
return redirect('appointment_list')
|
||||
|
||||
appointments = AppointmentModel.objects.all().order_by('-appointment_datetime')
|
||||
status_choices = AppointmentModel.STATUS_CHOICES
|
||||
|
||||
return render(request, "appointment_list.html", {
|
||||
"appointments": appointments,
|
||||
"status_choices": status_choices,
|
||||
})
|
||||
Reference in New Issue
Block a user