parent
02659f631a
commit
c31b677f98
@ -0,0 +1 @@ |
|||||||
|
This is a django customer relationship managemement (CRM) app. |
||||||
@ -0,0 +1,96 @@ |
|||||||
|
from django.contrib import admin |
||||||
|
from django.utils.html import format_html |
||||||
|
from .models import ( |
||||||
|
Prospect, |
||||||
|
Status, |
||||||
|
ProspectStatus, |
||||||
|
Event, |
||||||
|
EmailCampaign, |
||||||
|
EmailTracker |
||||||
|
) |
||||||
|
|
||||||
|
@admin.register(Prospect) |
||||||
|
class ProspectAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('name', 'email', 'region', 'created_at') |
||||||
|
list_filter = ('region', 'created_at') |
||||||
|
search_fields = ('name', 'email', 'region') |
||||||
|
filter_horizontal = ('users',) |
||||||
|
date_hierarchy = 'created_at' |
||||||
|
|
||||||
|
@admin.register(Status) |
||||||
|
class StatusAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('name', 'created_at') |
||||||
|
search_fields = ('name',) |
||||||
|
|
||||||
|
@admin.register(ProspectStatus) |
||||||
|
class ProspectStatusAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('prospect', 'status', 'created_at') |
||||||
|
list_filter = ('status', 'created_at') |
||||||
|
search_fields = ('prospect__name', 'prospect__email') |
||||||
|
date_hierarchy = 'created_at' |
||||||
|
|
||||||
|
@admin.register(Event) |
||||||
|
class EventAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('get_event_display', 'type', 'date', 'status', 'created_at') |
||||||
|
list_filter = ('type', 'status', 'date') |
||||||
|
search_fields = ('description',) |
||||||
|
filter_horizontal = ('prospects',) |
||||||
|
date_hierarchy = 'date' |
||||||
|
|
||||||
|
def get_event_display(self, obj): |
||||||
|
return str(obj) |
||||||
|
get_event_display.short_description = 'Event' |
||||||
|
|
||||||
|
@admin.register(EmailCampaign) |
||||||
|
class EmailCampaignAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('subject', 'event', 'sent_at') |
||||||
|
list_filter = ('sent_at',) |
||||||
|
search_fields = ('subject', 'content') |
||||||
|
date_hierarchy = 'sent_at' |
||||||
|
readonly_fields = ('sent_at',) |
||||||
|
|
||||||
|
@admin.register(EmailTracker) |
||||||
|
class EmailTrackerAdmin(admin.ModelAdmin): |
||||||
|
list_display = ( |
||||||
|
'campaign', |
||||||
|
'prospect', |
||||||
|
'tracking_id', |
||||||
|
'sent_status', |
||||||
|
'opened_status', |
||||||
|
'clicked_status' |
||||||
|
) |
||||||
|
list_filter = ('sent', 'opened', 'clicked') |
||||||
|
search_fields = ( |
||||||
|
'prospect__name', |
||||||
|
'prospect__email', |
||||||
|
'campaign__subject' |
||||||
|
) |
||||||
|
readonly_fields = ( |
||||||
|
'tracking_id', 'sent', 'sent_at', |
||||||
|
'opened', 'opened_at', |
||||||
|
'clicked', 'clicked_at' |
||||||
|
) |
||||||
|
date_hierarchy = 'sent_at' |
||||||
|
|
||||||
|
def sent_status(self, obj): |
||||||
|
return self._get_status_html(obj.sent, obj.sent_at) |
||||||
|
sent_status.short_description = 'Sent' |
||||||
|
sent_status.allow_tags = True |
||||||
|
|
||||||
|
def opened_status(self, obj): |
||||||
|
return self._get_status_html(obj.opened, obj.opened_at) |
||||||
|
opened_status.short_description = 'Opened' |
||||||
|
opened_status.allow_tags = True |
||||||
|
|
||||||
|
def clicked_status(self, obj): |
||||||
|
return self._get_status_html(obj.clicked, obj.clicked_at) |
||||||
|
clicked_status.short_description = 'Clicked' |
||||||
|
clicked_status.allow_tags = True |
||||||
|
|
||||||
|
def _get_status_html(self, status, date): |
||||||
|
if status: |
||||||
|
return format_html( |
||||||
|
'<span style="color: green;">✓</span> {}', |
||||||
|
date.strftime('%Y-%m-%d %H:%M') if date else '' |
||||||
|
) |
||||||
|
return format_html('<span style="color: red;">✗</span>') |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
from django.apps import AppConfig |
||||||
|
|
||||||
|
class CrmConfig(AppConfig): |
||||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||||
|
name = 'crm' |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
import django_filters |
||||||
|
|
||||||
|
from .models import Event, Status, Prospect |
||||||
|
|
||||||
|
class ProspectFilter(django_filters.FilterSet): |
||||||
|
region = django_filters.CharFilter(lookup_expr='icontains') |
||||||
|
events = django_filters.ModelMultipleChoiceFilter( |
||||||
|
queryset=Event.objects.all(), |
||||||
|
field_name='events', |
||||||
|
) |
||||||
|
statuses = django_filters.ModelMultipleChoiceFilter( |
||||||
|
queryset=Status.objects.all(), |
||||||
|
field_name='prospectstatus__status', |
||||||
|
) |
||||||
|
|
||||||
|
class Meta: |
||||||
|
model = Prospect |
||||||
|
fields = ['region', 'events', 'statuses'] |
||||||
@ -0,0 +1,40 @@ |
|||||||
|
from django import forms |
||||||
|
from .models import Prospect, Event |
||||||
|
import datetime |
||||||
|
|
||||||
|
class SmallTextArea(forms.Textarea): |
||||||
|
def __init__(self, *args, **kwargs): |
||||||
|
kwargs.setdefault('attrs', {}) |
||||||
|
kwargs['attrs'].update({ |
||||||
|
'rows': 2, |
||||||
|
'cols': 100, |
||||||
|
'style': 'height: 80px; width: 800px;' |
||||||
|
}) |
||||||
|
super().__init__(*args, **kwargs) |
||||||
|
|
||||||
|
class CSVImportForm(forms.Form): |
||||||
|
csv_file = forms.FileField() |
||||||
|
|
||||||
|
class BulkEmailForm(forms.Form): |
||||||
|
prospects = forms.ModelMultipleChoiceField( |
||||||
|
queryset=Prospect.objects.all(), |
||||||
|
widget=forms.CheckboxSelectMultiple |
||||||
|
) |
||||||
|
subject = forms.CharField(max_length=200) |
||||||
|
content = forms.CharField(widget=forms.Textarea) |
||||||
|
|
||||||
|
class EventForm(forms.ModelForm): |
||||||
|
prospects = forms.ModelMultipleChoiceField( |
||||||
|
queryset=Prospect.objects.all(), |
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'select2'}), |
||||||
|
required=False |
||||||
|
) |
||||||
|
description = forms.CharField(widget=SmallTextArea) |
||||||
|
attachment_text = forms.CharField(widget=SmallTextArea) |
||||||
|
|
||||||
|
class Meta: |
||||||
|
model = Event |
||||||
|
fields = ['date', 'type', 'description', 'attachment_text', 'prospects', 'status'] |
||||||
|
widgets = { |
||||||
|
'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}), |
||||||
|
} |
||||||
@ -0,0 +1,94 @@ |
|||||||
|
# Generated by Django 4.2.11 on 2024-12-08 15:10 |
||||||
|
|
||||||
|
from django.conf import settings |
||||||
|
from django.db import migrations, models |
||||||
|
import django.db.models.deletion |
||||||
|
import uuid |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
initial = True |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.CreateModel( |
||||||
|
name='Prospect', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('email', models.EmailField(max_length=254, unique=True)), |
||||||
|
('name', models.CharField(max_length=200)), |
||||||
|
('region', models.CharField(max_length=100)), |
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)), |
||||||
|
('users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='Status', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('name', models.CharField(max_length=100, unique=True)), |
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)), |
||||||
|
], |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='ProspectStatus', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)), |
||||||
|
('prospect', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='crm.prospect')), |
||||||
|
('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='crm.status')), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'ordering': ['-created_at'], |
||||||
|
}, |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='Event', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('date', models.DateTimeField()), |
||||||
|
('type', models.CharField(choices=[('MAIL', 'Mailing List'), ('SMS', 'SMS Campaign'), ('PRESS', 'Press Release')], max_length=10)), |
||||||
|
('description', models.TextField()), |
||||||
|
('attachment_text', models.TextField(blank=True)), |
||||||
|
('status', models.CharField(choices=[('PLANNED', 'Planned'), ('ACTIVE', 'Active'), ('COMPLETED', 'Completed')], max_length=20)), |
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)), |
||||||
|
('prospects', models.ManyToManyField(related_name='events', to='crm.prospect')), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'ordering': ['-created_at'], |
||||||
|
}, |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='EmailCampaign', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('subject', models.CharField(max_length=200)), |
||||||
|
('content', models.TextField()), |
||||||
|
('sent_at', models.DateTimeField(blank=True, null=True)), |
||||||
|
('event', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='crm.event')), |
||||||
|
], |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='EmailTracker', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('tracking_id', models.UUIDField(default=uuid.uuid4, editable=False)), |
||||||
|
('sent', models.BooleanField(default=False)), |
||||||
|
('sent_at', models.DateTimeField(blank=True, null=True)), |
||||||
|
('opened', models.BooleanField(default=False)), |
||||||
|
('opened_at', models.DateTimeField(blank=True, null=True)), |
||||||
|
('clicked', models.BooleanField(default=False)), |
||||||
|
('clicked_at', models.DateTimeField(blank=True, null=True)), |
||||||
|
('error_message', models.TextField(blank=True)), |
||||||
|
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='crm.emailcampaign')), |
||||||
|
('prospect', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='crm.prospect')), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'unique_together': {('campaign', 'prospect')}, |
||||||
|
}, |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
# Generated by Django 4.2.11 on 2024-12-08 20:58 |
||||||
|
|
||||||
|
from django.conf import settings |
||||||
|
from django.db import migrations, models |
||||||
|
import django.db.models.deletion |
||||||
|
import django.utils.timezone |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||||
|
('crm', '0001_initial'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AlterModelOptions( |
||||||
|
name='event', |
||||||
|
options={'ordering': ['-created_at'], 'permissions': [('manage_events', 'Can manage events'), ('view_events', 'Can view events')]}, |
||||||
|
), |
||||||
|
migrations.AlterModelOptions( |
||||||
|
name='prospect', |
||||||
|
options={'permissions': [('manage_prospects', 'Can manage prospects'), ('view_prospects', 'Can view prospects')]}, |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='event', |
||||||
|
name='created_by', |
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_events', to=settings.AUTH_USER_MODEL), |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='event', |
||||||
|
name='modified_at', |
||||||
|
field=models.DateTimeField(auto_now=True), |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='event', |
||||||
|
name='modified_by', |
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_events', to=settings.AUTH_USER_MODEL), |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='prospect', |
||||||
|
name='created_by', |
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_prospects', to=settings.AUTH_USER_MODEL), |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='prospect', |
||||||
|
name='modified_at', |
||||||
|
field=models.DateTimeField(auto_now=True), |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='prospect', |
||||||
|
name='modified_by', |
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_prospects', to=settings.AUTH_USER_MODEL), |
||||||
|
), |
||||||
|
migrations.AlterField( |
||||||
|
model_name='event', |
||||||
|
name='date', |
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now), |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin |
||||||
|
from django.core.exceptions import PermissionDenied |
||||||
|
|
||||||
|
class CRMAccessMixin(LoginRequiredMixin, UserPassesTestMixin): |
||||||
|
def test_func(self): |
||||||
|
return self.request.user.groups.filter(name='CRM Manager').exists() |
||||||
@ -0,0 +1,112 @@ |
|||||||
|
from django.db import models |
||||||
|
from django.contrib.auth import get_user_model |
||||||
|
from django.utils import timezone |
||||||
|
import uuid |
||||||
|
|
||||||
|
User = get_user_model() |
||||||
|
|
||||||
|
class EventType(models.TextChoices): |
||||||
|
MAILING = 'MAIL', 'Mailing List' |
||||||
|
SMS = 'SMS', 'SMS Campaign' |
||||||
|
PRESS = 'PRESS', 'Press Release' |
||||||
|
|
||||||
|
class Prospect(models.Model): |
||||||
|
email = models.EmailField(unique=True) |
||||||
|
name = models.CharField(max_length=200) |
||||||
|
region = models.CharField(max_length=100) |
||||||
|
users = models.ManyToManyField(get_user_model(), blank=True) |
||||||
|
created_at = models.DateTimeField(auto_now_add=True) |
||||||
|
created_by = models.ForeignKey( |
||||||
|
User, |
||||||
|
on_delete=models.SET_NULL, |
||||||
|
null=True, |
||||||
|
related_name='created_prospects' |
||||||
|
) |
||||||
|
modified_by = models.ForeignKey( |
||||||
|
User, |
||||||
|
on_delete=models.SET_NULL, |
||||||
|
null=True, |
||||||
|
related_name='modified_prospects' |
||||||
|
) |
||||||
|
modified_at = models.DateTimeField(auto_now=True) |
||||||
|
|
||||||
|
class Meta: |
||||||
|
permissions = [ |
||||||
|
("manage_prospects", "Can manage prospects"), |
||||||
|
("view_prospects", "Can view prospects"), |
||||||
|
] |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return f"{self.name} ({self.email})" |
||||||
|
|
||||||
|
class Status(models.Model): |
||||||
|
name = models.CharField(max_length=100, unique=True) |
||||||
|
created_at = models.DateTimeField(auto_now_add=True) |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return self.name |
||||||
|
|
||||||
|
class ProspectStatus(models.Model): |
||||||
|
prospect = models.ForeignKey(Prospect, on_delete=models.CASCADE) |
||||||
|
status = models.ForeignKey(Status, on_delete=models.PROTECT) |
||||||
|
created_at = models.DateTimeField(auto_now_add=True) |
||||||
|
|
||||||
|
class Meta: |
||||||
|
ordering = ['-created_at'] |
||||||
|
|
||||||
|
class Event(models.Model): |
||||||
|
date = models.DateTimeField(default=timezone.now) |
||||||
|
type = models.CharField(max_length=10, choices=EventType.choices) |
||||||
|
description = models.TextField() |
||||||
|
attachment_text = models.TextField(blank=True) |
||||||
|
prospects = models.ManyToManyField(Prospect, related_name='events') |
||||||
|
status = models.CharField(max_length=20, choices=[ |
||||||
|
('PLANNED', 'Planned'), |
||||||
|
('ACTIVE', 'Active'), |
||||||
|
('COMPLETED', 'Completed'), |
||||||
|
]) |
||||||
|
created_at = models.DateTimeField(auto_now_add=True) |
||||||
|
created_by = models.ForeignKey( |
||||||
|
User, |
||||||
|
on_delete=models.SET_NULL, |
||||||
|
null=True, |
||||||
|
related_name='created_events' |
||||||
|
) |
||||||
|
modified_by = models.ForeignKey( |
||||||
|
User, |
||||||
|
on_delete=models.SET_NULL, |
||||||
|
null=True, |
||||||
|
related_name='modified_events' |
||||||
|
) |
||||||
|
modified_at = models.DateTimeField(auto_now=True) |
||||||
|
|
||||||
|
class Meta: |
||||||
|
ordering = ['-created_at'] |
||||||
|
permissions = [ |
||||||
|
("manage_events", "Can manage events"), |
||||||
|
("view_events", "Can view events"), |
||||||
|
] |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return f"{self.get_type_display()} - {self.date.date()}" |
||||||
|
|
||||||
|
class EmailCampaign(models.Model): |
||||||
|
event = models.OneToOneField(Event, on_delete=models.CASCADE) |
||||||
|
subject = models.CharField(max_length=200) |
||||||
|
content = models.TextField() |
||||||
|
sent_at = models.DateTimeField(null=True, blank=True) |
||||||
|
|
||||||
|
class EmailTracker(models.Model): |
||||||
|
campaign = models.ForeignKey(EmailCampaign, on_delete=models.CASCADE) |
||||||
|
prospect = models.ForeignKey(Prospect, on_delete=models.CASCADE) |
||||||
|
tracking_id = models.UUIDField(default=uuid.uuid4, editable=False) |
||||||
|
sent = models.BooleanField(default=False) |
||||||
|
sent_at = models.DateTimeField(null=True, blank=True) |
||||||
|
opened = models.BooleanField(default=False) |
||||||
|
opened_at = models.DateTimeField(null=True, blank=True) |
||||||
|
clicked = models.BooleanField(default=False) |
||||||
|
clicked_at = models.DateTimeField(null=True, blank=True) |
||||||
|
error_message = models.TextField(blank=True) |
||||||
|
|
||||||
|
class Meta: |
||||||
|
unique_together = ['campaign', 'prospect'] |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
# services.py |
||||||
|
from django.core.mail import send_mail, get_connection |
||||||
|
from django.conf import settings |
||||||
|
from django.template.loader import render_to_string |
||||||
|
|
||||||
|
def send_bulk_email(subject, content, prospects): |
||||||
|
""" |
||||||
|
Send bulk emails to prospects |
||||||
|
Returns tuple of (success_count, error_count) |
||||||
|
""" |
||||||
|
success_count = 0 |
||||||
|
error_count = 0 |
||||||
|
|
||||||
|
# Get email connection |
||||||
|
connection = get_connection() |
||||||
|
|
||||||
|
# You might want to wrap this in try/except if you want to handle connection errors |
||||||
|
connection.open() |
||||||
|
|
||||||
|
for prospect in prospects: |
||||||
|
try: |
||||||
|
# You could add basic personalization here |
||||||
|
personalized_content = content.replace('{name}', prospect.name) |
||||||
|
|
||||||
|
send_mail( |
||||||
|
subject=subject, |
||||||
|
message=personalized_content, # Plain text version |
||||||
|
html_message=personalized_content, # HTML version |
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL, |
||||||
|
recipient_list=[prospect.email], |
||||||
|
connection=connection, |
||||||
|
fail_silently=False, |
||||||
|
) |
||||||
|
success_count += 1 |
||||||
|
|
||||||
|
except Exception as e: |
||||||
|
error_count += 1 |
||||||
|
# You might want to log the error here |
||||||
|
print(f"Failed to send email to {prospect.email}: {str(e)}") |
||||||
|
|
||||||
|
connection.close() |
||||||
|
|
||||||
|
return success_count, error_count |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
document.addEventListener("DOMContentLoaded", function () { |
||||||
|
const selectAll = document.getElementById("select-all"); |
||||||
|
const prospectCheckboxes = document.getElementsByName("selected_prospects"); |
||||||
|
|
||||||
|
selectAll.addEventListener("change", function () { |
||||||
|
prospectCheckboxes.forEach((checkbox) => { |
||||||
|
checkbox.checked = selectAll.checked; |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
{% extends "crm/base.html" %} {% block content %} |
||||||
|
<div class="container padding-bottom"> |
||||||
|
<div class="grid-x padding-bottom"> |
||||||
|
<div class="cell medium-6 large-6 my-block bubble"> |
||||||
|
<h1 class="title">Add New Prospect</h1> |
||||||
|
|
||||||
|
<form method="post"> |
||||||
|
{% csrf_token %} |
||||||
|
<div class="form-group"> |
||||||
|
<label for="name">Name:</label> |
||||||
|
<input type="text" name="name" id="name" required /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-group"> |
||||||
|
<label for="email">Email:</label> |
||||||
|
<input type="email" name="email" id="email" required /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-group"> |
||||||
|
<label for="region">Region:</label> |
||||||
|
<input type="text" name="region" id="region" required /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<button type="submit" class="small-button margin-v20"> |
||||||
|
Save Prospect |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,71 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html> |
||||||
|
{% load static %} |
||||||
|
|
||||||
|
<head> |
||||||
|
<meta http-equiv="content-type" content="text/html; charset=UTF-8" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||||
|
<link |
||||||
|
rel="stylesheet" |
||||||
|
href="{% static 'tournaments/css/foundation.min.css' %}" |
||||||
|
/> |
||||||
|
<link rel="stylesheet" href="{% static 'tournaments/css/style.css' %}" /> |
||||||
|
<link rel="stylesheet" href="{% static 'tournaments/css/basics.css' %}" /> |
||||||
|
|
||||||
|
<link |
||||||
|
rel="icon" |
||||||
|
type="image/png" |
||||||
|
href="{% static 'tournaments/images/favicon.png' %}" |
||||||
|
/> |
||||||
|
|
||||||
|
<title>{% block head_title %}Page Title{% endblock %} - Padel Club</title> |
||||||
|
<!-- Matomo --> |
||||||
|
<script> |
||||||
|
var _paq = window._paq = window._paq || []; |
||||||
|
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */ |
||||||
|
_paq.push(["setDocumentTitle", document.domain + "/" + document.title]); |
||||||
|
_paq.push(["setDoNotTrack", true]); |
||||||
|
_paq.push(['trackPageView']); |
||||||
|
_paq.push(['enableLinkTracking']); |
||||||
|
(function() { |
||||||
|
var u="//matomo.padelclub.app/"; |
||||||
|
_paq.push(['setTrackerUrl', u+'matomo.php']); |
||||||
|
_paq.push(['setSiteId', '1']); |
||||||
|
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; |
||||||
|
g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
<!-- End Matomo Code --> |
||||||
|
|
||||||
|
</head> |
||||||
|
|
||||||
|
<body class="wrapper"> |
||||||
|
<header> |
||||||
|
<div class="grid-x"> |
||||||
|
<div class="medium-6 large-9 cell topblock my-block "> |
||||||
|
<a href="{% url 'index' %}"> |
||||||
|
<img |
||||||
|
src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" |
||||||
|
class="logo inline" |
||||||
|
/> |
||||||
|
<div class="inline padding-left"> |
||||||
|
<h1 class="club">{% block first_title %}Page Title{% endblock %}</h1> |
||||||
|
<h1 class="event">{% block second_title %}Page Title{% endblock %}</h1> |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
{% block right_header %}{% endblock %} |
||||||
|
</div> |
||||||
|
</header> |
||||||
|
|
||||||
|
<main> |
||||||
|
<!-- Content --> |
||||||
|
{% block content %} |
||||||
|
<!-- The content of child templates will be inserted here --> |
||||||
|
{% endblock %} |
||||||
|
</main> |
||||||
|
|
||||||
|
<footer/> |
||||||
|
|
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
{% extends "crm/base.html" %} {% block content %} |
||||||
|
<div class="container"> |
||||||
|
<div class="grid-x padding-bottom"> |
||||||
|
<div class="cell medium-6 large-6 my-block bubble"> |
||||||
|
<h1 class="title"> |
||||||
|
{% if form.instance.pk %}Edit{% else %}Add{% endif %} Event |
||||||
|
</h1> |
||||||
|
|
||||||
|
<form method="post"> |
||||||
|
{% csrf_token %} {{ form }} |
||||||
|
|
||||||
|
<div class="mt-3"> |
||||||
|
<button type="submit" class="btn small-button"> |
||||||
|
Save Event |
||||||
|
</button> |
||||||
|
<a |
||||||
|
href="{% url 'crm:planned_events' %}" |
||||||
|
class="btn btn-secondary" |
||||||
|
>Cancel</a |
||||||
|
> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
<div class="bottom-border"> |
||||||
|
<div class="table-row-3-colums"> |
||||||
|
<div class="left-column"> |
||||||
|
<div class="semibold">{{ event.get_type_display }}</div> |
||||||
|
<div class="minor-info">{{ event.description|truncatechars:100 }}</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="right-column"> |
||||||
|
<span>{{ event.date|date:"d/m/Y H:i" }}</span> |
||||||
|
<a href="{% url 'crm:edit_event' event.id %}" class="small-button">Edit</a> |
||||||
|
<!-- {% if event.status == 'PLANNED' %} |
||||||
|
<a href="{% url 'crm:start_event' event.id %}" class="small-button">Start</a> |
||||||
|
{% endif %} --> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -0,0 +1,58 @@ |
|||||||
|
{% extends "crm/base.html" %} |
||||||
|
{% load crm_tags %} |
||||||
|
|
||||||
|
|
||||||
|
{% block content %} |
||||||
|
|
||||||
|
{% if request.user|is_crm_manager %} |
||||||
|
|
||||||
|
<div class="d-flex"> |
||||||
|
<a href="{% url 'crm:add_event' %}" class="small-button margin-v20"> |
||||||
|
Add Event |
||||||
|
</a> |
||||||
|
<a href="{% url 'crm:add_prospect' %}" class="small-button margin-v20 left-margin"> |
||||||
|
Add Prospect |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="container grid-x padding-bottom"> |
||||||
|
<div class="cell medium-6 large-6 my-block bubble"> |
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4"> |
||||||
|
<h1 class="title">Completed Events</h1> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="list-group"> |
||||||
|
{% for event in completed_events %} |
||||||
|
{% include "crm/event_row.html" with event=event %} |
||||||
|
{% empty %} |
||||||
|
<div class="list-group-item">No completed events.</div> |
||||||
|
{% endfor %} |
||||||
|
</div> |
||||||
|
|
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="cell medium-6 large-6 my-block bubble"> |
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4"> |
||||||
|
<h1 class="title">Planned Events</h1> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="list-group"> |
||||||
|
{% for event in planned_events %} |
||||||
|
{% include "crm/event_row.html" with event=event %} |
||||||
|
{% empty %} |
||||||
|
<div class="list-group-item">No planned events.</div> |
||||||
|
{% endfor %} |
||||||
|
</div> |
||||||
|
|
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% else %} |
||||||
|
|
||||||
|
Not authorized |
||||||
|
|
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,57 @@ |
|||||||
|
{% extends "crm/base.html" %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="container bubble"> |
||||||
|
<h2>Prospects</h2> |
||||||
|
|
||||||
|
<div class="card mb-4"> |
||||||
|
<div class="card-body"> |
||||||
|
<form method="get" class="row g-3"> |
||||||
|
{{ filter.form }} |
||||||
|
<div class="col-12"> |
||||||
|
<button type="submit" class="btn btn-primary">Filter</button> |
||||||
|
<a href="{% url 'crm:prospect-list' %}" class="btn btn-secondary">Clear</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="mb-3"> |
||||||
|
<a href="{% url 'crm:prospect-import' %}" class="btn btn-success">Import CSV</a> |
||||||
|
<a href="{% url 'crm:send-bulk-email' %}" class="btn btn-primary">Send Email</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="table-responsive"> |
||||||
|
<table class="table table-striped"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th><input type="checkbox" id="select-all"></th> |
||||||
|
<th>Name</th> |
||||||
|
<th>Email</th> |
||||||
|
<th>Region</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for prospect in prospects %} |
||||||
|
<tr> |
||||||
|
<td><input type="checkbox" name="selected_prospects" value="{{ prospect.id }}"></td> |
||||||
|
<td>{{ prospect.name }}</td> |
||||||
|
<td>{{ prospect.email }}</td> |
||||||
|
<td>{{ prospect.region }}</td> |
||||||
|
<td> |
||||||
|
{% for status in prospect.prospectstatus_set.all %} |
||||||
|
<span class="badge bg-primary">{{ status.status.name }}</span> |
||||||
|
{% endfor %} |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
<button class="btn btn-sm btn-secondary">Edit</button> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,47 @@ |
|||||||
|
{% extends "crm/base.html" %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="container mt-4"> |
||||||
|
<h2>Send Bulk Email</h2> |
||||||
|
|
||||||
|
<form method="post"> |
||||||
|
{% csrf_token %} |
||||||
|
|
||||||
|
<div class="mb-3"> |
||||||
|
<label class="form-label">{{ form.prospects.label }}</label> |
||||||
|
{{ form.prospects }} |
||||||
|
{% if form.prospects.errors %} |
||||||
|
<div class="alert alert-danger"> |
||||||
|
{{ form.prospects.errors }} |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="mb-3"> |
||||||
|
<label class="form-label">{{ form.subject.label }}</label> |
||||||
|
{{ form.subject }} |
||||||
|
{% if form.subject.errors %} |
||||||
|
<div class="alert alert-danger"> |
||||||
|
{{ form.subject.errors }} |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="mb-3"> |
||||||
|
<label class="form-label">{{ form.content.label }}</label> |
||||||
|
{{ form.content }} |
||||||
|
{% if form.content.errors %} |
||||||
|
<div class="alert alert-danger"> |
||||||
|
{{ form.content.errors }} |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
<div class="form-text"> |
||||||
|
You can use {name} to insert the prospect's name. |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Send Email</button> |
||||||
|
<a href="{% url 'crm:prospect-list' %}" class="btn btn-secondary">Cancel</a> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
from django import template |
||||||
|
|
||||||
|
register = template.Library() |
||||||
|
|
||||||
|
@register.filter(name='is_crm_manager') |
||||||
|
def is_crm_manager(user): |
||||||
|
return user.groups.filter(name='CRM Manager').exists() |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.test import TestCase |
||||||
|
|
||||||
|
# Create your tests here. |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
from django.urls import path |
||||||
|
from . import views |
||||||
|
|
||||||
|
app_name = 'crm' |
||||||
|
|
||||||
|
urlpatterns = [ |
||||||
|
path('', views.EventListView.as_view(), name='planned_events'),path('', views.EventListView.as_view(), name='planned-events'), |
||||||
|
path('events/add/', views.EventCreateView.as_view(), name='add_event'), |
||||||
|
path('events/<int:pk>/edit/', views.EditEventView.as_view(), name='edit_event'), |
||||||
|
path('events/<int:pk>/start/', views.StartEventView.as_view(), name='start_event'), |
||||||
|
path('prospects/', views.ProspectListView.as_view(), name='prospect-list'), |
||||||
|
path('add-prospect/', views.add_prospect, name='add_prospect'), |
||||||
|
path('prospects/import/', views.CSVImportView.as_view(), name='prospect-import'), |
||||||
|
path('email/send/', views.SendBulkEmailView.as_view(), name='send-bulk-email'), |
||||||
|
] |
||||||
@ -0,0 +1,186 @@ |
|||||||
|
# views.py |
||||||
|
from django.views.generic import FormView, ListView, DetailView, CreateView, UpdateView |
||||||
|
from django.views.generic.edit import FormView, BaseUpdateView |
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin |
||||||
|
from django.contrib.auth.decorators import permission_required |
||||||
|
from django.contrib import messages |
||||||
|
from django.shortcuts import render, redirect, get_object_or_404 |
||||||
|
from django.urls import reverse_lazy |
||||||
|
from django.http import HttpResponse, HttpResponseRedirect |
||||||
|
from django.views import View |
||||||
|
from django.utils import timezone |
||||||
|
from django.contrib.sites.shortcuts import get_current_site |
||||||
|
from django.template.loader import render_to_string |
||||||
|
from django.core.mail import send_mail |
||||||
|
from django.conf import settings |
||||||
|
|
||||||
|
from .models import Event, Prospect, EmailTracker, EmailCampaign, EventType |
||||||
|
from .filters import ProspectFilter |
||||||
|
from .forms import CSVImportForm, BulkEmailForm, EventForm |
||||||
|
|
||||||
|
from .mixins import CRMAccessMixin |
||||||
|
|
||||||
|
import csv |
||||||
|
from io import TextIOWrapper |
||||||
|
from datetime import datetime |
||||||
|
|
||||||
|
@permission_required('crm.view_crm', raise_exception=True) |
||||||
|
def add_prospect(request): |
||||||
|
if request.method == 'POST': |
||||||
|
name = request.POST.get('name') |
||||||
|
email = request.POST.get('email') |
||||||
|
region = request.POST.get('region') |
||||||
|
|
||||||
|
try: |
||||||
|
prospect = Prospect.objects.create( |
||||||
|
name=name, |
||||||
|
email=email, |
||||||
|
region=region, |
||||||
|
created_by=request.user, |
||||||
|
modified_by=request.user |
||||||
|
) |
||||||
|
messages.success(request, f'Prospect {name} has been added successfully!') |
||||||
|
return redirect('crm:events') # or wherever you want to redirect after success |
||||||
|
except Exception as e: |
||||||
|
messages.error(request, f'Error adding prospect: {str(e)}') |
||||||
|
|
||||||
|
return render(request, 'crm/add_prospect.html') |
||||||
|
|
||||||
|
class EventCreateView(CRMAccessMixin, CreateView): |
||||||
|
model = Event |
||||||
|
form_class = EventForm |
||||||
|
template_name = 'crm/event_form.html' |
||||||
|
success_url = reverse_lazy('crm:planned_events') |
||||||
|
|
||||||
|
def form_valid(self, form): |
||||||
|
form.instance.created_by = self.request.user |
||||||
|
form.instance.modified_by = self.request.user |
||||||
|
return super().form_valid(form) |
||||||
|
|
||||||
|
class EditEventView(CRMAccessMixin, UpdateView): |
||||||
|
model = Event |
||||||
|
form_class = EventForm |
||||||
|
template_name = 'crm/event_form.html' |
||||||
|
success_url = reverse_lazy('crm:planned_events') |
||||||
|
|
||||||
|
def form_valid(self, form): |
||||||
|
form.instance.modified_by = self.request.user |
||||||
|
response = super().form_valid(form) |
||||||
|
messages.success(self.request, 'Event updated successfully!') |
||||||
|
return response |
||||||
|
|
||||||
|
class StartEventView(CRMAccessMixin, BaseUpdateView): |
||||||
|
model = Event |
||||||
|
http_method_names = ['post', 'get'] |
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs): |
||||||
|
return self.post(request, *args, **kwargs) |
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs): |
||||||
|
event = get_object_or_404(Event, pk=kwargs['pk'], status='PLANNED') |
||||||
|
event.status = 'ACTIVE' |
||||||
|
event.save() |
||||||
|
|
||||||
|
if event.type == 'MAIL': |
||||||
|
return HttpResponseRedirect( |
||||||
|
reverse_lazy('crm:setup_email_campaign', kwargs={'event_id': event.id}) |
||||||
|
) |
||||||
|
elif event.type == 'SMS': |
||||||
|
return HttpResponseRedirect( |
||||||
|
reverse_lazy('crm:setup_sms_campaign', kwargs={'event_id': event.id}) |
||||||
|
) |
||||||
|
elif event.type == 'PRESS': |
||||||
|
return HttpResponseRedirect( |
||||||
|
reverse_lazy('crm:setup_press_release', kwargs={'event_id': event.id}) |
||||||
|
) |
||||||
|
|
||||||
|
messages.success(request, 'Event started successfully!') |
||||||
|
return HttpResponseRedirect(reverse_lazy('crm:planned_events')) |
||||||
|
|
||||||
|
class EventListView(CRMAccessMixin, ListView): |
||||||
|
model = Event |
||||||
|
template_name = 'crm/events.html' |
||||||
|
context_object_name = 'events' # We won't use this since we're providing custom context |
||||||
|
|
||||||
|
def get_context_data(self, **kwargs): |
||||||
|
context = super().get_context_data(**kwargs) |
||||||
|
context['planned_events'] = Event.objects.filter( |
||||||
|
status='PLANNED' |
||||||
|
).order_by('date') |
||||||
|
context['completed_events'] = Event.objects.filter( |
||||||
|
status='COMPLETED' |
||||||
|
).order_by('-date') |
||||||
|
return context |
||||||
|
|
||||||
|
class ProspectListView(CRMAccessMixin, ListView): |
||||||
|
model = Prospect |
||||||
|
template_name = 'crm/prospect_list.html' |
||||||
|
context_object_name = 'prospects' |
||||||
|
filterset_class = ProspectFilter |
||||||
|
|
||||||
|
def get_context_data(self, **kwargs): |
||||||
|
context = super().get_context_data(**kwargs) |
||||||
|
context['filter'] = self.filterset_class( |
||||||
|
self.request.GET, |
||||||
|
queryset=self.get_queryset() |
||||||
|
) |
||||||
|
return context |
||||||
|
|
||||||
|
class CSVImportView(CRMAccessMixin, FormView): |
||||||
|
template_name = 'crm/csv_import.html' |
||||||
|
form_class = CSVImportForm |
||||||
|
success_url = reverse_lazy('prospect-list') |
||||||
|
|
||||||
|
def form_valid(self, form): |
||||||
|
csv_file = TextIOWrapper( |
||||||
|
form.cleaned_data['csv_file'].file, |
||||||
|
encoding='utf-8' |
||||||
|
) |
||||||
|
reader = csv.DictReader(csv_file) |
||||||
|
|
||||||
|
for row in reader: |
||||||
|
Prospect.objects.create( |
||||||
|
email=row['email'], |
||||||
|
name=row.get('name', ''), |
||||||
|
region=row.get('region', ''), |
||||||
|
created_by=self.request.user, |
||||||
|
modified_by=self.request.user |
||||||
|
) |
||||||
|
|
||||||
|
return super().form_valid(form) |
||||||
|
|
||||||
|
class SendBulkEmailView(CRMAccessMixin, FormView): |
||||||
|
template_name = 'crm/send_bulk_email.html' |
||||||
|
form_class = BulkEmailForm |
||||||
|
success_url = reverse_lazy('crm:prospect-list') |
||||||
|
|
||||||
|
def form_valid(self, form): |
||||||
|
prospects = form.cleaned_data['prospects'] |
||||||
|
subject = form.cleaned_data['subject'] |
||||||
|
content = form.cleaned_data['content'] |
||||||
|
|
||||||
|
# Create Event for this email campaign |
||||||
|
event = Event.objects.create( |
||||||
|
date=datetime.now(), |
||||||
|
type=EventType.MAILING, |
||||||
|
description=f"Bulk email: {subject}", |
||||||
|
status='COMPLETED', |
||||||
|
created_by=self.request.user, |
||||||
|
modified_by=self.request.user |
||||||
|
) |
||||||
|
event.prospects.set(prospects) |
||||||
|
|
||||||
|
# Send emails |
||||||
|
success_count, error_count = send_bulk_email( |
||||||
|
subject=subject, |
||||||
|
content=content, |
||||||
|
prospects=prospects |
||||||
|
) |
||||||
|
|
||||||
|
# Show result message |
||||||
|
messages.success( |
||||||
|
self.request, |
||||||
|
f"Sent {success_count} emails successfully. {error_count} failed." |
||||||
|
) |
||||||
|
|
||||||
|
return super().form_valid(form) |
||||||
Loading…
Reference in new issue