online_registration
Raz 11 months ago
commit febb571788
  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. 6
      tournaments/admin.py
  30. 36
      tournaments/models/tournament.py
  31. 1838
      tournaments/static/rankings/CLASSEMENT-PADEL-MESSIEURS-12-2024.csv
  32. 4
      tournaments/static/tournaments/css/basics.css
  33. 27
      tournaments/static/tournaments/css/style.css
  34. 16
      tournaments/templates/tournaments/admin/tournament_cleaner.html
  35. 2
      tournaments/templates/tournaments/team_row.html
  36. 1
      tournaments/urls.py
  37. 118
      tournaments/views.py

@ -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 = [
'tournaments',
# 'crm',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@ -43,6 +44,7 @@ INSTALLED_APPS = [
'rest_framework.authtoken',
'dj_rest_auth',
'qr_code',
# 'django_filters',
]
AUTH_USER_MODEL = "tournaments.CustomUser"

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

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

@ -27,7 +27,7 @@ class CustomUserAdmin(UserAdmin):
'summons_message_body', 'summons_message_signature', 'summons_available_payment_methods',
'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',
'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode'
'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode', 'groups'
]}),
]
add_fieldsets = [
@ -35,7 +35,7 @@ class CustomUserAdmin(UserAdmin):
None,
{
"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'],
},
),
]
@ -71,7 +71,7 @@ class PlayerRegistrationAdmin(admin.ModelAdmin):
ordering = ['last_name', 'first_name']
class MatchAdmin(admin.ModelAdmin):
list_display = ['__str__', 'round', 'group_stage', 'start_date', 'index']
list_display = ['__str__', 'round', 'group_stage', 'start_date', 'end_date', 'index']
list_filter = [MatchTypeListFilter, MatchTournamentListFilter, SimpleIndexListFilter]
ordering = ['-group_stage', 'round']

@ -478,6 +478,8 @@ class Tournament(models.Model):
return groups
def group_stage_match_group(self, group_stage, broadcasted, hide_empty_matches):
if group_stage is None:
return None
matches = group_stage.match_set.all()
if hide_empty_matches:
matches = [m for m in matches if m.should_appear()]
@ -516,7 +518,7 @@ class Tournament(models.Model):
key=lambda m: (
m.round.index, # Sort by Round index first
m.round.get_depth(),
m.name, # Then by Round depth
m.name or '', # Then by Round depth, using empty string if name is None
)
)
group = self.create_match_group('Matchs de classement', ranking_matches)
@ -647,6 +649,7 @@ class Tournament(models.Model):
matches.extend(first_round.get_matches_recursive(True))
else:
current_round = self.round_to_show()
# print(f'current_round = {current_round.index} / parent = {current_round.parent}')
if current_round:
all_upper_matches_are_over = current_round.all_matches_are_over()
if all_upper_matches_are_over is False:
@ -655,16 +658,20 @@ class Tournament(models.Model):
# Add full matches from the next rounds
next_round = self.round_for_index(current_round.index - 1)
if next_round:
# print('next round')
matches.extend(next_round.get_matches_recursive(True))
if all_upper_matches_are_over is True:
# print('all_upper_matches_are_over')
matches.extend(current_round.get_matches_recursive(True))
# Add matches from the previous round or group_stages
previous_round = self.round_for_index(current_round.index + 1)
if previous_round:
# print('previous_round')
matches.extend(previous_round.get_matches_recursive(True))
else:
# print('group_stages')
group_stages = [gs.live_group_stages() for gs in self.last_group_stage_step()]
return matches, group_stages
@ -705,6 +712,7 @@ class Tournament(models.Model):
def first_unfinished_match(self):
matches = [m for m in self.all_matches(False) if m.start_date and m.end_date is None]
# print(f'first_unfinished_match > match len: {len(matches)}')
matches.sort(key=lambda m: m.start_date)
if matches:
return matches[0]
@ -712,15 +720,26 @@ class Tournament(models.Model):
return None
def round_to_show(self):
# print('===== round_to_show')
last_started_match = self.first_unfinished_match()
if last_started_match:
# print(f'last_started_match = {last_started_match.name}')
current_round = last_started_match.round.root_round()
# print(f'round_to_show > current_round: {current_round.name()}')
if current_round:
return current_round
main_rounds = list(self.round_set.filter(parent=None).all())
main_rounds.sort(key=lambda r: r.index)
if main_rounds:
return main_rounds[0]
# all started matches have ended, possibly
last_finished_match = self.last_finished_match()
# print(f'last_finished_match = {last_finished_match.name}')
round_root_index = last_finished_match.round.root_round().index
# print(f'round_index = {round_root_index}')
if round_root_index == 0:
return last_finished_match.round
else:
round = self.round_set.filter(parent=None,index=round_root_index-1).first()
if round:
return round
else:
return None
@ -729,6 +748,11 @@ class Tournament(models.Model):
matches.sort(key=lambda m: m.start_date, reverse=True)
return matches[0] if matches else None
def last_finished_match(self):
matches = [m for m in self.all_matches(False) if m.end_date]
matches.sort(key=lambda m: m.end_date, reverse=True)
return matches[0] if matches else None
def round_for_index(self, index):
return self.round_set.filter(index=index, parent=None).first()
@ -1180,6 +1204,7 @@ class TeamSummon:
class TeamItem:
def __init__(self, team_registration):
self.names = team_registration.team_names()
self.date = team_registration.local_call_date()
self.registration_date = team_registration.registration_date
self.weight = team_registration.weight
self.initial_weight = team_registration.initial_weight()
@ -1195,6 +1220,7 @@ class TeamItem:
def to_dict(self):
return {
"names": self.names,
"date": self.date,
"registration_date": self.registration_date,
"weight": self.weight,
"initial_weight": self.initial_weight,

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

@ -144,6 +144,21 @@ tr {
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 {
background-color: #fae7ce; /* Green background */
color: #707070; /* White text */
@ -162,11 +177,21 @@ tr {
.numbers {
font-feature-settings: "tnum";
}
.red {
color: #e84039;
}
.orange {
color: #f39200;
}
.yellow {
color: #fed300;
}
.blue {
color: #1a223a;
}
.mybox {
color: #707070;
padding: 8px 12px;

@ -0,0 +1,16 @@
<!-- templates/admin_import.html -->
<!DOCTYPE html>
<html>
<head>
<title>Tournament Import</title>
</head>
<body>
<form method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div class="upload-zone">
<input type="file" name="tournament_zip" accept=".zip">
</div>
<button type="submit">Process Import</button>
</form>
</body>
</html>

@ -14,7 +14,7 @@
</div>
{% else %}
<div class="table-cell table-cell-large semibold">
<div>Réservé</div>
<div>Bloqué</div>
</div>
{% endif %}
{% if tournament.hide_teams_weight %}

@ -51,4 +51,5 @@ urlpatterns = [
path('password_reset_done/', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'),
path('reset/done/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
path('profile/', views.ProfileUpdateView.as_view(), name='profile'),
path('admin/tournament-import/', views.tournament_import_view, name='tournament_import'),
]

@ -1,10 +1,36 @@
# Standard library imports
import os
import csv
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponse
from django.utils.encoding import force_str
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from django.contrib.admin.views.decorators import staff_member_required
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from tournaments.models.device_token import DeviceToken
from .models import Court, DateInterval, Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall
from .models import TeamSummon
from datetime import datetime, timedelta
import time
from django.template import loader
from datetime import date
from django.http import JsonResponse, HttpResponse
from django.db.models import Q
import json
import time
import asyncio
from datetime import date, datetime, timedelta
import csv
import zipfile
from api.tokens import account_activation_token
# Third-party imports
from qr_code.qrcode.utils import QRCodeOptions
@ -709,3 +735,95 @@ class ProfileUpdateView(UpdateView):
context = super().get_context_data(**kwargs)
context['password_change_form'] = CustomPasswordChangeForm(user=self.request.user)
return context
from api.serializers import GroupStageSerializer, MatchSerializer, PlayerRegistrationSerializer, RoundSerializer, TeamRegistrationSerializer, TeamScoreSerializer
@staff_member_required
def tournament_import_view(request):
if request.method == 'POST':
zip_file = request.FILES.get('tournament_zip')
if zip_file:
try:
tournament_id = os.path.splitext(zip_file.name)[0]
tournament = Tournament.objects.get(id=tournament_id)
# Delete existing relationships
tournament.round_set.all().delete()
tournament.groupstage_set.all().delete()
tournament.teamregistration_set.all().delete()
with zipfile.ZipFile(zip_file) as z:
# First, process rounds
rounds_data = get_file_data(z, f"{tournament_id}/rounds.json")
if rounds_data:
# First pass: Create rounds without parent relationships
rounds_without_parent = []
for item in rounds_data:
item['tournament'] = tournament.id
# Temporarily remove parent field
parent = item.pop('parent', None)
rounds_without_parent.append({'data': item, 'parent': parent})
serializer = RoundSerializer(data=[item['data'] for item in rounds_without_parent], many=True)
serializer.is_valid(raise_exception=True)
created_rounds = serializer.save()
# Create a mapping of round IDs
round_mapping = {round_obj.id: round_obj for round_obj in created_rounds}
# Second pass: Update parent relationships
for round_obj, original_data in zip(created_rounds, rounds_without_parent):
if original_data['parent']:
round_obj.parent = round_mapping.get(original_data['parent'])
round_obj.save()
# Then process all other files
serializer_mapping = {
'group-stages.json': GroupStageSerializer,
'team-registrations.json': TeamRegistrationSerializer,
'matches.json': MatchSerializer,
'player-registrations.json': PlayerRegistrationSerializer,
'team-scores.json': TeamScoreSerializer
}
# Process each remaining file
for filename, serializer_class in serializer_mapping.items():
process_file(z, filename, tournament_id, tournament, serializer_class)
return JsonResponse({'status': 'success'})
except Exception as e:
return JsonResponse({'status': 'error', 'message': str(e)})
else:
return render(request, 'tournaments/admin/tournament_cleaner.html')
def process_file(zip_file, filename, tournament_id, tournament, serializer_class):
"""Helper function to process individual files"""
try:
file_path = f"{tournament_id}/{filename}"
json_data = get_file_data(zip_file, file_path)
if json_data:
# Add tournament to each item
for item in json_data:
item['tournament'] = tournament.id
serializer = serializer_class(data=json_data, many=True)
serializer.is_valid(raise_exception=True)
serializer.save()
except Exception as e:
print(f"Error processing {filename}: {str(e)}")
def get_file_data(zip_file, file_path):
"""Helper function to read and parse JSON data from zip file"""
try:
file_content = zip_file.read(file_path)
return json.loads(file_content)
except KeyError:
print(f"File not found: {file_path}")
return None
except json.JSONDecodeError as e:
print(f"JSON Error for {file_path}: {str(e)}")
raise Exception(f"Invalid JSON in file {file_path}")

Loading…
Cancel
Save