diff --git a/crm/__init__.py b/crm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/_instructions/base.md b/crm/_instructions/base.md new file mode 100644 index 0000000..a45abd0 --- /dev/null +++ b/crm/_instructions/base.md @@ -0,0 +1 @@ +This is a django customer relationship managemement (CRM) app. diff --git a/crm/admin.py b/crm/admin.py new file mode 100644 index 0000000..c6858ff --- /dev/null +++ b/crm/admin.py @@ -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( + '✓ {}', + date.strftime('%Y-%m-%d %H:%M') if date else '' + ) + return format_html('✗') diff --git a/crm/apps.py b/crm/apps.py new file mode 100644 index 0000000..7593b92 --- /dev/null +++ b/crm/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class CrmConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'crm' diff --git a/crm/filters.py b/crm/filters.py new file mode 100644 index 0000000..a78b63f --- /dev/null +++ b/crm/filters.py @@ -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'] diff --git a/crm/forms.py b/crm/forms.py new file mode 100644 index 0000000..efd945a --- /dev/null +++ b/crm/forms.py @@ -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'}), + } diff --git a/crm/migrations/0001_initial.py b/crm/migrations/0001_initial.py new file mode 100644 index 0000000..66531fe --- /dev/null +++ b/crm/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/crm/migrations/0002_alter_event_options_alter_prospect_options_and_more.py b/crm/migrations/0002_alter_event_options_alter_prospect_options_and_more.py new file mode 100644 index 0000000..92b7621 --- /dev/null +++ b/crm/migrations/0002_alter_event_options_alter_prospect_options_and_more.py @@ -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), + ), + ] diff --git a/crm/migrations/__init__.py b/crm/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/mixins.py b/crm/mixins.py new file mode 100644 index 0000000..debe594 --- /dev/null +++ b/crm/mixins.py @@ -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() diff --git a/crm/models.py b/crm/models.py new file mode 100644 index 0000000..e9be339 --- /dev/null +++ b/crm/models.py @@ -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'] diff --git a/crm/services.py b/crm/services.py new file mode 100644 index 0000000..9ac5f5e --- /dev/null +++ b/crm/services.py @@ -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 diff --git a/crm/static/js/prospect_list.js b/crm/static/js/prospect_list.js new file mode 100644 index 0000000..9378e4a --- /dev/null +++ b/crm/static/js/prospect_list.js @@ -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; + }); + }); +}); diff --git a/crm/templates/crm/add_prospect.html b/crm/templates/crm/add_prospect.html new file mode 100644 index 0000000..01aeba1 --- /dev/null +++ b/crm/templates/crm/add_prospect.html @@ -0,0 +1,31 @@ +{% extends "crm/base.html" %} {% block content %} +
| + | Name | +Region | +Status | +Actions | +|
|---|---|---|---|---|---|
| + | {{ prospect.name }} | +{{ prospect.email }} | +{{ prospect.region }} | ++ {% for status in prospect.prospectstatus_set.all %} + {{ status.status.name }} + {% endfor %} + | ++ + | +