Adds CRM project, first push

sync
Laurent 11 months ago
parent 02659f631a
commit c31b677f98
  1. 0
      crm/__init__.py
  2. 1
      crm/_instructions/base.md
  3. 96
      crm/admin.py
  4. 5
      crm/apps.py
  5. 18
      crm/filters.py
  6. 40
      crm/forms.py
  7. 94
      crm/migrations/0001_initial.py
  8. 60
      crm/migrations/0002_alter_event_options_alter_prospect_options_and_more.py
  9. 0
      crm/migrations/__init__.py
  10. 6
      crm/mixins.py
  11. 112
      crm/models.py
  12. 43
      crm/services.py
  13. 10
      crm/static/js/prospect_list.js
  14. 31
      crm/templates/crm/add_prospect.html
  15. 71
      crm/templates/crm/base.html
  16. 27
      crm/templates/crm/event_form.html
  17. 16
      crm/templates/crm/event_row.html
  18. 58
      crm/templates/crm/events.html
  19. 57
      crm/templates/crm/prospect_list.html
  20. 47
      crm/templates/crm/send_bulk_email.html
  21. 0
      crm/templatetags/__init__.py
  22. 7
      crm/templatetags/crm_tags.py
  23. 3
      crm/tests.py
  24. 15
      crm/urls.py
  25. 186
      crm/views.py
  26. 2
      padelclub_backend/settings.py
  27. 1
      padelclub_backend/urls.py
  28. 1
      requirements.txt
  29. 4
      tournaments/admin.py
  30. 4
      tournaments/static/tournaments/css/basics.css
  31. 27
      tournaments/static/tournaments/css/style.css

@ -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)

@ -33,6 +33,7 @@ ALLOWED_HOSTS = ['*']
INSTALLED_APPS = [ INSTALLED_APPS = [
'tournaments', 'tournaments',
'crm',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@ -43,6 +44,7 @@ INSTALLED_APPS = [
'rest_framework.authtoken', 'rest_framework.authtoken',
'dj_rest_auth', 'dj_rest_auth',
'qr_code', 'qr_code',
'django_filters',
] ]
AUTH_USER_MODEL = "tournaments.CustomUser" AUTH_USER_MODEL = "tournaments.CustomUser"

@ -20,6 +20,7 @@ urlpatterns = [
# path('roads/', include(router.urls)), # path('roads/', include(router.urls)),
path("", include("tournaments.urls")), path("", include("tournaments.urls")),
path("crm/", include("crm.urls")),
path('roads/', include("api.urls")), path('roads/', include("api.urls")),
path('kingdom/', admin.site.urls), path('kingdom/', admin.site.urls),
path('api-auth/', include('rest_framework.urls')), path('api-auth/', include('rest_framework.urls')),

@ -10,3 +10,4 @@ httpx[http2]==0.27.0
pandas==2.2.2 pandas==2.2.2
xlrd==2.0.1 xlrd==2.0.1
openpyxl==3.1.5 openpyxl==3.1.5
django-filter==24.3

@ -25,7 +25,7 @@ class CustomUserAdmin(UserAdmin):
'summons_message_body', 'summons_message_signature', 'summons_available_payment_methods', 'summons_message_body', 'summons_message_signature', 'summons_available_payment_methods',
'summons_display_format', 'summons_display_entry_fee', 'summons_use_full_custom_message', 'summons_display_format', 'summons_display_entry_fee', 'summons_use_full_custom_message',
'match_formats_default_duration', 'bracket_match_format_preference', 'group_stage_match_format_preference', 'match_formats_default_duration', 'bracket_match_format_preference', 'group_stage_match_format_preference',
'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode' 'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode', 'groups'
]}), ]}),
] ]
add_fieldsets = [ add_fieldsets = [
@ -33,7 +33,7 @@ class CustomUserAdmin(UserAdmin):
None, None,
{ {
"classes": ["wide"], "classes": ["wide"],
"fields": ['username', 'email', 'password1', 'password2', 'first_name', 'last_name', 'clubs', 'country', 'phone', 'licence_id', 'umpire_code', ], "fields": ['username', 'email', 'password1', 'password2', 'first_name', 'last_name', 'clubs', 'country', 'phone', 'licence_id', 'umpire_code', 'groups'],
}, },
), ),
] ]

@ -48,6 +48,10 @@
margin-top: 150px; margin-top: 150px;
} }
.margin-v20 {
margin: 20px 0px;
}
/* WIDTH PERCENTAGE */ /* WIDTH PERCENTAGE */
.w15 { .w15 {

@ -124,6 +124,21 @@ tr {
font-weight: 600; font-weight: 600;
} */ } */
.small-button {
color: white;
background-color: #f39200;
padding: 4px 8px;
font-size: 1em;
font-weight: 800;
border-radius: 8px;
display: inline-block;
}
.small-button:hover {
color: white;
background-color: #e84039;
}
.rounded-button { .rounded-button {
background-color: #f39200; /* Green background */ background-color: #f39200; /* Green background */
color: white; /* White text */ color: white; /* White text */
@ -137,11 +152,21 @@ tr {
.numbers { .numbers {
font-feature-settings: "tnum"; font-feature-settings: "tnum";
} }
.red {
color: #e84039;
}
.orange { .orange {
color: #f39200; color: #f39200;
} }
.yellow {
color: #fed300;
}
.blue {
color: #1a223a;
}
.mybox { .mybox {
color: #707070; color: #707070;
padding: 8px 12px; padding: 8px 12px;

Loading…
Cancel
Save