diff --git a/.gitignore b/.gitignore index 4e82b22..2f99ee3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ padelclub_backend/settings_local.py myenv/ -tournaments/config_local.py +shared/config_local.py # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/api/serializers.py b/api/serializers.py index a05f817..5704780 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -9,10 +9,25 @@ from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode from django.utils.encoding import force_bytes from django.core.mail import EmailMessage from django.contrib.sites.shortcuts import get_current_site - from api.tokens import account_activation_token # from tournaments.models.data_access import DataAccess +from shared.cryptography import encryption_util +from tournaments.models.draw_log import DrawLog + +class EncryptedUserField(serializers.Field): + def to_representation(self, value): + # Encrypt data when sending it out + return encryption_util.encrypt_aes_gcm(str(value.id)) + + def to_internal_value(self, data): + # Decrypt data when receiving it + decrypted_user_id = encryption_util.decrypt_aes_gcm(data) + user = CustomUser.objects.get(id=decrypted_user_id) + if decrypted_user_id is None: + raise serializers.ValidationError("Invalid encrypted data") + return user + class UserSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True) @@ -55,6 +70,7 @@ class UserSerializer(serializers.ModelSerializer): bracket_match_format_preference=validated_data.get('bracket_match_format_preference'), group_stage_match_format_preference=validated_data.get('group_stage_match_format_preference'), loser_bracket_match_format_preference=validated_data.get('loser_bracket_match_format_preference'), + loser_bracket_mode=validated_data.get('loser_bracket_mode'), ) self.send_email(self.context['request'], user) @@ -104,27 +120,17 @@ class TournamentSerializer(serializers.ModelSerializer): class Meta: model = Tournament fields = '__all__' - # ['id', 'name', 'event', 'creator_id', 'start_date', 'end_date', 'creation_date', - # 'is_private', 'format', 'group_stage_format', 'round_format', 'loser_round_format', 'bracket_sort_mode', - # 'group_stage_count', 'rank_source_date', 'day_duration', 'team_count', 'team_sorting', - # 'federal_category', 'federal_level_category', 'federal_age_category', 'group_stage_court_count', - # 'seed_count', 'closed_registration_date', 'group_stage_additional_qualified', 'court_count', 'prioritize_club_members', - # 'qualified_per_group_stage', 'teams_per_group_stage'] class EventSerializer(serializers.ModelSerializer): class Meta: #club_id = serializers.PrimaryKeyRelatedField(queryset=Club.objects.all()) model = Event fields = '__all__' - # ['id', 'club_id', 'date', 'name', 'federal_tournament_data', 'court_count', 'tenup_id', - # 'group_stage_format', 'round_format', 'loser_round_format'] class RoundSerializer(serializers.ModelSerializer): class Meta: - # tournament_id = serializers.PrimaryKeyRelatedField(queryset=Tournament.objects.all()) - # loser_id = serializers.PrimaryKeyRelatedField(queryset=Round.objects.all()) model = Round - fields = '__all__' #['id', 'index', 'tournament_id', 'loser', 'format'] + fields = '__all__' class GroupStageSerializer(serializers.ModelSerializer): class Meta: @@ -133,38 +139,26 @@ class GroupStageSerializer(serializers.ModelSerializer): class MatchSerializer(serializers.ModelSerializer): class Meta: - # round_id = serializers.PrimaryKeyRelatedField(queryset=Round.objects.all()) - # group_stage_id = serializers.PrimaryKeyRelatedField(queryset=GroupStage.objects.all()) model = Match fields = '__all__' - # ['id', 'round_id', 'group_stage_id', 'index', 'format', 'court', 'start_date', 'end_date', - # 'serving_team_id', 'winning_team_id', 'losing_team_id'] class TeamScoreSerializer(serializers.ModelSerializer): class Meta: - # match_id = serializers.PrimaryKeyRelatedField(queryset=Match.objects.all()) - # player_registrations_ids = serializers.Man model = TeamScore fields = '__all__' # ['id', 'match_id', 'score', 'walk_out', 'lucky_loser', 'player_registrations'] class TeamRegistrationSerializer(serializers.ModelSerializer): class Meta: - # match_id = serializers.PrimaryKeyRelatedField(queryset=Match.objects.all()) - # group_stage_id = serializers.PrimaryKeyRelatedField(queryset=GroupStage.objects.all()) model = TeamRegistration fields = '__all__' - # ['id', 'group_stage_id', 'registration_date', 'call_date', 'bracket_position', - # 'group_stage_position', 'logo'] class PlayerRegistrationSerializer(serializers.ModelSerializer): class Meta: - # team_registration_id = serializers.PrimaryKeyRelatedField(queryset=TeamRegistration.objects.all()) - # team_state_id = serializers.PrimaryKeyRelatedField(queryset=TeamState.objects.all()) model = PlayerRegistration fields = '__all__' - # ['id', 'team_registration_id', 'first_name', 'last_name', 'licence_id', 'rank', 'has_paid'] class PurchaseSerializer(serializers.ModelSerializer): + user = EncryptedUserField() class Meta: model = Purchase @@ -232,8 +226,8 @@ class DeviceTokenSerializer(serializers.ModelSerializer): fields = '__all__' read_only_fields = ['user'] -# class DataAccessSerializer(serializers.ModelSerializer): -# class Meta: -# model = DataAccess -# fields = '__all__' -# read_only_fields = ['user'] + +class DrawLogSerializer(serializers.ModelSerializer): + class Meta: + model = DrawLog + fields = '__all__' diff --git a/api/urls.py b/api/urls.py index ed56847..a6d37c5 100644 --- a/api/urls.py +++ b/api/urls.py @@ -20,6 +20,7 @@ router.register(r'player-registrations', views.PlayerRegistrationViewSet) router.register(r'purchases', views.PurchaseViewSet) router.register(r'courts', views.CourtViewSet) router.register(r'date-intervals', views.DateIntervalViewSet) +router.register(r'draw-logs', views.DrawLogViewSet) router.register(r'failed-api-calls', views.FailedApiCallViewSet) router.register(r'logs', views.LogViewSet) router.register(r'device-token', views.DeviceTokenViewSet) diff --git a/api/views.py b/api/views.py index 989d755..42bef74 100644 --- a/api/views.py +++ b/api/views.py @@ -1,5 +1,6 @@ -from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, TournamentSerializer, UserSerializer, ChangePasswordSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, LiveMatchSerializer, PurchaseSerializer, CustomUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, ShortUserSerializer -from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken +from pandas.io.feather_format import pd +from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, ChangePasswordSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, LiveMatchSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer +from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog from rest_framework import viewsets, permissions from rest_framework.authtoken.models import Token @@ -162,7 +163,7 @@ class RoundViewSet(viewsets.ModelViewSet): serializer_class = RoundSerializer def get_queryset(self): - tournament_id = self.request.query_params.get('store_id') + tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament') if tournament_id: return self.queryset.filter(tournament=tournament_id) if self.request.user: @@ -174,7 +175,7 @@ class GroupStageViewSet(viewsets.ModelViewSet): serializer_class = GroupStageSerializer def get_queryset(self): - tournament_id = self.request.query_params.get('store_id') + tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament') if tournament_id: return self.queryset.filter(tournament=tournament_id) if self.request.user: @@ -186,7 +187,7 @@ class MatchViewSet(viewsets.ModelViewSet): serializer_class = MatchSerializer def get_queryset(self): - tournament_id = self.request.query_params.get('store_id') + tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament') if tournament_id: return self.queryset.filter(Q(group_stage__tournament=tournament_id) | Q(round__tournament=tournament_id)) if self.request.user: @@ -198,9 +199,10 @@ class TeamScoreViewSet(viewsets.ModelViewSet): serializer_class = TeamScoreSerializer def get_queryset(self): - tournament_id = self.request.query_params.get('store_id') + tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament') if tournament_id: - return self.queryset.filter(team_registration__tournament=tournament_id) + q = Q(team_registration__tournament=tournament_id) | Q(match__group_stage__tournament=tournament_id) | Q(match__round__tournament=tournament_id) + return self.queryset.filter(q) if self.request.user: return self.queryset.filter(team_registration__tournament__event__creator=self.request.user) return [] @@ -210,7 +212,7 @@ class TeamRegistrationViewSet(viewsets.ModelViewSet): serializer_class = TeamRegistrationSerializer def get_queryset(self): - tournament_id = self.request.query_params.get('store_id') + tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament') if tournament_id: return self.queryset.filter(tournament=tournament_id) if self.request.user: @@ -222,7 +224,7 @@ class PlayerRegistrationViewSet(viewsets.ModelViewSet): serializer_class = PlayerRegistrationSerializer def get_queryset(self): - tournament_id = self.request.query_params.get('store_id') + tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament') if tournament_id: return self.queryset.filter(team_registration__tournament=tournament_id) if self.request.user: @@ -295,3 +297,15 @@ class DeviceTokenViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): serializer.save(user=self.request.user) + +class DrawLogViewSet(viewsets.ModelViewSet): + queryset = DrawLog.objects.all() + serializer_class = DrawLogSerializer + + def get_queryset(self): + tournament_id = self.request.query_params.get('tournament') or self.request.query_params.get('tournament') + if tournament_id: + return self.queryset.filter(tournament=tournament_id) + if self.request.user: + return self.queryset.filter(tournament__event__creator=self.request.user) + return [] 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 %} +
+
+
+

Add New Prospect

+ +
+ {% csrf_token %} +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+
+{% endblock %} diff --git a/crm/templates/crm/base.html b/crm/templates/crm/base.html new file mode 100644 index 0000000..14cf3de --- /dev/null +++ b/crm/templates/crm/base.html @@ -0,0 +1,71 @@ + + + {% load static %} + + + + + + + + + + + {% block head_title %}Page Title{% endblock %} - Padel Club + + + + + + + +
+
+ + {% block right_header %}{% endblock %} +
+
+ +
+ + {% block content %} + + {% endblock %} +
+ +