Compare commits
2 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
7185e62e9d | 1 year ago |
|
|
eb7ea048c5 | 1 year ago |
@ -1,10 +0,0 @@ |
||||
This is a django project that is used for padel tournaments management. |
||||
Here are the different apps: |
||||
- api: the api is used to communicate with the mobile app |
||||
- authentication: regroups authentications services |
||||
- biz: it's our CRM project to manage customers |
||||
- shop: the website that hosts the shop |
||||
- sync: the project used to synchronize the data between apps and the backend |
||||
- tournaments: the main website the display everything about the padel tournaments |
||||
|
||||
In production, the project runs with ASGI because we use websockets in the sync app. |
||||
@ -1,22 +0,0 @@ |
||||
from django.contrib import admin |
||||
from rest_framework_api_key.admin import APIKeyModelAdmin |
||||
from rest_framework_api_key.models import APIKey as DefaultAPIKey |
||||
from .models import APIKey |
||||
|
||||
# Unregister the default APIKey admin |
||||
admin.site.unregister(DefaultAPIKey) |
||||
|
||||
|
||||
@admin.register(APIKey) |
||||
class APIKeyAdmin(APIKeyModelAdmin): |
||||
list_display = [*APIKeyModelAdmin.list_display, "user"] |
||||
list_filter = [*APIKeyModelAdmin.list_filter, "user"] |
||||
search_fields = [*APIKeyModelAdmin.search_fields, "user__username", "user__email"] |
||||
raw_id_fields = ['user'] |
||||
|
||||
def get_form(self, request, obj=None, **kwargs): |
||||
form = super().get_form(request, obj, **kwargs) |
||||
# Make user field required |
||||
if 'user' in form.base_fields: |
||||
form.base_fields['user'].required = True |
||||
return form |
||||
@ -1,7 +0,0 @@ |
||||
from django.apps import AppConfig |
||||
|
||||
|
||||
class ApiConfig(AppConfig): |
||||
default_auto_field = 'django.db.models.BigAutoField' |
||||
name = 'api' |
||||
verbose_name = 'API' |
||||
@ -1,24 +0,0 @@ |
||||
from rest_framework_api_key.permissions import BaseHasAPIKey |
||||
from .models import APIKey |
||||
|
||||
|
||||
class HasAPIKey(BaseHasAPIKey): |
||||
model = APIKey |
||||
|
||||
def has_permission(self, request, view): |
||||
# First check if we have a valid API key |
||||
has_api_key = super().has_permission(request, view) |
||||
|
||||
if has_api_key: |
||||
# Get the API key from the request |
||||
key = self.get_key(request) |
||||
if key: |
||||
try: |
||||
api_key = APIKey.objects.get_from_key(key) |
||||
# Set the request.user to the user associated with the API key |
||||
request.user = api_key.user |
||||
return True |
||||
except APIKey.DoesNotExist: |
||||
pass |
||||
|
||||
return False |
||||
@ -1,36 +0,0 @@ |
||||
# Generated by Django 5.1 on 2025-09-17 07:49 |
||||
|
||||
import django.db.models.deletion |
||||
from django.conf import settings |
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
initial = True |
||||
|
||||
dependencies = [ |
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.CreateModel( |
||||
name='APIKey', |
||||
fields=[ |
||||
('id', models.CharField(editable=False, max_length=150, primary_key=True, serialize=False, unique=True)), |
||||
('prefix', models.CharField(editable=False, max_length=8, unique=True)), |
||||
('hashed_key', models.CharField(editable=False, max_length=150)), |
||||
('created', models.DateTimeField(auto_now_add=True, db_index=True)), |
||||
('name', models.CharField(default=None, help_text='A free-form name for the API key. Need not be unique. 50 characters max.', max_length=50)), |
||||
('revoked', models.BooleanField(blank=True, default=False, help_text='If the API key is revoked, clients cannot use it anymore. (This cannot be undone.)')), |
||||
('expiry_date', models.DateTimeField(blank=True, help_text='Once API key expires, clients cannot use it anymore.', null=True, verbose_name='Expires')), |
||||
('user', models.ForeignKey(help_text='The user this API key belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)), |
||||
], |
||||
options={ |
||||
'verbose_name': 'API Key', |
||||
'verbose_name_plural': 'API Keys', |
||||
'ordering': ('-created',), |
||||
'abstract': False, |
||||
}, |
||||
), |
||||
] |
||||
@ -1,23 +0,0 @@ |
||||
from django.db import models |
||||
from rest_framework_api_key.models import AbstractAPIKey |
||||
from tournaments.models import CustomUser |
||||
|
||||
|
||||
class APIKey(AbstractAPIKey): |
||||
""" |
||||
API Key model linked to a specific user. |
||||
This allows filtering API access based on the user associated with the API key. |
||||
""" |
||||
user = models.ForeignKey( |
||||
CustomUser, |
||||
on_delete=models.CASCADE, |
||||
related_name='api_keys', |
||||
help_text='The user this API key belongs to' |
||||
) |
||||
|
||||
class Meta(AbstractAPIKey.Meta): |
||||
verbose_name = "API Key" |
||||
verbose_name_plural = "API Keys" |
||||
|
||||
def __str__(self): |
||||
return f"API Key for {self.user.username}" |
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,15 +0,0 @@ |
||||
from django.contrib import admin |
||||
|
||||
from .models import Device, LoginLog |
||||
|
||||
class DeviceAdmin(admin.ModelAdmin): |
||||
list_display = ['user', 'device_model', 'last_login', 'id'] |
||||
readonly_fields = ('last_login',) |
||||
ordering = ['-last_login'] |
||||
|
||||
class LoginLogAdmin(admin.ModelAdmin): |
||||
list_display = ['user', 'device', 'date'] |
||||
ordering = ['-date'] |
||||
|
||||
admin.site.register(Device, DeviceAdmin) |
||||
admin.site.register(LoginLog, LoginLogAdmin) |
||||
@ -1,6 +0,0 @@ |
||||
from django.apps import AppConfig |
||||
|
||||
|
||||
class AuthenticationConfig(AppConfig): |
||||
default_auto_field = 'django.db.models.BigAutoField' |
||||
name = 'authentication' |
||||
@ -1,36 +0,0 @@ |
||||
# Generated by Django 5.1 on 2025-03-20 14:49 |
||||
|
||||
import django.db.models.deletion |
||||
import uuid |
||||
from django.conf import settings |
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
initial = True |
||||
|
||||
dependencies = [ |
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.CreateModel( |
||||
name='Device', |
||||
fields=[ |
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||
('last_login', models.DateTimeField(auto_now=True)), |
||||
('model_name', models.CharField(blank=True, max_length=100, null=True)), |
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devices', to=settings.AUTH_USER_MODEL)), |
||||
], |
||||
), |
||||
migrations.CreateModel( |
||||
name='LoginLog', |
||||
fields=[ |
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||
('date', models.DateTimeField(auto_now=True)), |
||||
('device', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='login_logs', to='authentication.device')), |
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_logs', to=settings.AUTH_USER_MODEL)), |
||||
], |
||||
), |
||||
] |
||||
@ -1,18 +0,0 @@ |
||||
# Generated by Django 5.1 on 2025-03-20 15:42 |
||||
|
||||
from django.db import migrations |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('authentication', '0001_initial'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.RenameField( |
||||
model_name='device', |
||||
old_name='model_name', |
||||
new_name='device_model', |
||||
), |
||||
] |
||||
@ -1,2 +0,0 @@ |
||||
from .device import Device |
||||
from .login_log import LoginLog |
||||
@ -1,12 +0,0 @@ |
||||
from django.db import models |
||||
import uuid |
||||
from django.conf import settings |
||||
|
||||
class Device(models.Model): |
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='devices') |
||||
last_login = models.DateTimeField(auto_now=True) |
||||
device_model = models.CharField(max_length=100, blank=True, null=True) |
||||
|
||||
def __str__(self): |
||||
return f"{self.user.username} : {self.device_model}" |
||||
@ -1,13 +0,0 @@ |
||||
from django.db import models |
||||
import uuid |
||||
from django.conf import settings |
||||
from . import Device |
||||
|
||||
class LoginLog(models.Model): |
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='login_logs') |
||||
device = models.ForeignKey(Device, on_delete=models.SET_NULL, related_name='login_logs', null=True) |
||||
date = models.DateTimeField(auto_now=True) |
||||
|
||||
def __str__(self): |
||||
return f"{self.id} > {self.user.username}" |
||||
@ -1,29 +0,0 @@ |
||||
from django.contrib.auth import password_validation |
||||
|
||||
from rest_framework import serializers |
||||
|
||||
class ChangePasswordSerializer(serializers.Serializer): |
||||
old_password = serializers.CharField(max_length=128, write_only=True, required=True) |
||||
new_password1 = serializers.CharField(max_length=128, write_only=True, required=True) |
||||
new_password2 = serializers.CharField(max_length=128, write_only=True, required=True) |
||||
|
||||
def validate_old_password(self, value): |
||||
user = self.context['request'].user |
||||
if not user.check_password(value): |
||||
raise serializers.ValidationError( |
||||
_('Your old password was entered incorrectly. Please enter it again.') |
||||
) |
||||
return value |
||||
|
||||
def validate(self, data): |
||||
if data['new_password1'] != data['new_password2']: |
||||
raise serializers.ValidationError({'new_password2': _("The two password fields didn't match.")}) |
||||
password_validation.validate_password(data['new_password1'], self.context['request'].user) |
||||
return data |
||||
|
||||
def save(self, **kwargs): |
||||
password = self.validated_data['new_password1'] |
||||
user = self.context['request'].user |
||||
user.set_password(password) |
||||
user.save() |
||||
return user |
||||
@ -1,3 +0,0 @@ |
||||
from django.test import TestCase |
||||
|
||||
# Create your tests here. |
||||
@ -1,5 +0,0 @@ |
||||
import re |
||||
|
||||
def is_valid_email(email): |
||||
email_regex = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' |
||||
return re.match(email_regex, email) is not None |
||||
@ -1,116 +0,0 @@ |
||||
from django.shortcuts import render |
||||
from django.views.decorators.csrf import csrf_exempt |
||||
from django.contrib.auth import authenticate |
||||
from django.utils.decorators import method_decorator |
||||
from django.core.exceptions import ObjectDoesNotExist |
||||
from django.conf import settings |
||||
from django.contrib.auth import get_user_model |
||||
|
||||
from rest_framework.views import APIView |
||||
from rest_framework.response import Response |
||||
from rest_framework.permissions import IsAuthenticated |
||||
from rest_framework.authtoken.models import Token |
||||
from rest_framework import status |
||||
from rest_framework.generics import UpdateAPIView |
||||
|
||||
from .utils import is_valid_email |
||||
from .models import Device, LoginLog |
||||
|
||||
from .serializers import ChangePasswordSerializer |
||||
import logging |
||||
|
||||
CustomUser=get_user_model() |
||||
logger = logging.getLogger(__name__) |
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch') |
||||
class CustomAuthToken(APIView): |
||||
permission_classes = [] |
||||
|
||||
def post(self, request, *args, **kwargs): |
||||
username = request.data.get('username') |
||||
password = request.data.get('password') |
||||
device_id = request.data.get('device_id') |
||||
|
||||
# logger.info(f'Login attempt from {username}') |
||||
user = authenticate(username=username, password=password) |
||||
|
||||
if user is None and is_valid_email(username) == True: |
||||
true_username = self.get_username_from_email(username) |
||||
user = authenticate(username=true_username, password=password) |
||||
|
||||
if user: |
||||
user.device_id = device_id |
||||
user.save() |
||||
|
||||
device_model = request.data.get('device_model') |
||||
device = self.create_or_update_device(user, device_id, device_model) |
||||
self.create_login_log(user, device) |
||||
|
||||
token, created = Token.objects.get_or_create(user=user) |
||||
return Response({'token': token.key}) |
||||
|
||||
# if user.device_id is None or user.device_id == device_id or user.username == 'apple-test': |
||||
# user.device_id = device_id |
||||
# user.save() |
||||
|
||||
# device_model = request.data.get('device_model') |
||||
|
||||
# device = self.create_or_update_device(user, device_id, device_model) |
||||
# self.create_login_log(user, device) |
||||
|
||||
# token, created = Token.objects.get_or_create(user=user) |
||||
# return Response({'token': token.key}) |
||||
# else: |
||||
# return Response({'error': 'Vous ne pouvez pour l\'instant vous connecter sur plusieurs appareils en même temps. Veuillez vous déconnecter du précédent appareil. Autrement, veuillez contacter le support.'}, status=status.HTTP_403_FORBIDDEN) |
||||
|
||||
else: |
||||
return Response({'error': 'L\'utilisateur et le mot de passe de correspondent pas'}, status=status.HTTP_401_UNAUTHORIZED) |
||||
|
||||
def create_or_update_device(self, user, device_id, device_model): |
||||
obj, created = Device.objects.update_or_create( |
||||
id=device_id, |
||||
device_model=device_model, |
||||
defaults={ |
||||
'user': user |
||||
} |
||||
) |
||||
return obj |
||||
|
||||
def create_login_log(self, user, device): |
||||
LoginLog.objects.create(user=user, device=device) |
||||
|
||||
def get_username_from_email(self, email): |
||||
try: |
||||
user = CustomUser.objects.get(email=email) |
||||
return user.username |
||||
except ObjectDoesNotExist: |
||||
return None |
||||
|
||||
class Logout(APIView): |
||||
permission_classes = (IsAuthenticated,) |
||||
|
||||
def post(self, request, *args, **kwargs): |
||||
# request.user.auth_token.delete() |
||||
|
||||
device_id = request.data.get('device_id') |
||||
if request.user.device_id == device_id: |
||||
request.user.device_id = None |
||||
request.user.save() |
||||
|
||||
Device.objects.filter(id=device_id).delete() |
||||
|
||||
return Response(status=status.HTTP_200_OK) |
||||
|
||||
class ChangePasswordView(UpdateAPIView): |
||||
serializer_class = ChangePasswordSerializer |
||||
|
||||
def update(self, request, *args, **kwargs): |
||||
serializer = self.get_serializer(data=request.data) |
||||
serializer.is_valid(raise_exception=True) |
||||
user = serializer.save() |
||||
# if using drf authtoken, create a new token |
||||
if hasattr(user, 'auth_token'): |
||||
user.auth_token.delete() |
||||
token, created = Token.objects.get_or_create(user=user) |
||||
# return new token |
||||
return Response({'token': token.key}, status=status.HTTP_200_OK) |
||||
@ -1,534 +0,0 @@ |
||||
from django.http import HttpResponseRedirect |
||||
from django.contrib import admin |
||||
from django.urls import path, reverse |
||||
from django.contrib import messages |
||||
from django.shortcuts import render, redirect |
||||
from django.contrib.auth import get_user_model |
||||
from django.utils.html import format_html |
||||
from django.core.mail import send_mail |
||||
from django.db.models import Q, Max, Subquery, OuterRef |
||||
|
||||
import csv |
||||
import io |
||||
import time |
||||
import logging |
||||
|
||||
from .models import Entity, Prospect, Activity, Status, ActivityType, EmailTemplate, DeclinationReason, ProspectGroup |
||||
from .forms import FileImportForm, EmailTemplateSelectionForm |
||||
from .filters import ContactAgainFilter, ProspectStatusFilter, StaffUserFilter, ProspectProfileFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter |
||||
|
||||
from tournaments.models import CustomUser |
||||
from tournaments.models.enums import UserOrigin |
||||
from sync.admin import SyncedObjectAdmin |
||||
|
||||
User = get_user_model() |
||||
|
||||
logger = logging.getLogger(__name__) |
||||
|
||||
class ProspectInline(admin.StackedInline): |
||||
model = Prospect.entities.through |
||||
extra = 1 |
||||
verbose_name = "Prospect" |
||||
verbose_name_plural = "Prospects" |
||||
autocomplete_fields = ['prospect'] |
||||
|
||||
@admin.register(Entity) |
||||
class EntityAdmin(SyncedObjectAdmin): |
||||
list_display = ('name', 'address', 'zip_code', 'city') |
||||
search_fields = ('name', 'address', 'zip_code', 'city') |
||||
# filter_horizontal = ('prospects',) |
||||
inlines = [ProspectInline] |
||||
|
||||
@admin.register(EmailTemplate) |
||||
class EmailTemplateAdmin(SyncedObjectAdmin): |
||||
list_display = ('name', 'subject', 'body') |
||||
search_fields = ('name', 'subject') |
||||
exclude = ('data_access_ids', 'activities',) |
||||
|
||||
def contacted_by_sms(modeladmin, request, queryset): |
||||
create_default_activity_for_prospect(modeladmin, request, queryset, ActivityType.SMS, Status.CONTACTED, None) |
||||
contacted_by_sms.short_description = "Contacted by SMS" |
||||
|
||||
def mark_as_inbound(modeladmin, request, queryset): |
||||
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.INBOUND, None) |
||||
mark_as_inbound.short_description = "Mark as inbound" |
||||
|
||||
def mark_as_customer(modeladmin, request, queryset): |
||||
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.CUSTOMER, None) |
||||
mark_as_customer.short_description = "Mark as customer" |
||||
|
||||
def mark_as_should_test(modeladmin, request, queryset): |
||||
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.SHOULD_TEST, None) |
||||
mark_as_should_test.short_description = "Mark as should test" |
||||
|
||||
def mark_as_testing(modeladmin, request, queryset): |
||||
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.TESTING, None) |
||||
mark_as_testing.short_description = "Mark as testing" |
||||
|
||||
def declined_too_expensive(modeladmin, request, queryset): |
||||
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.TOO_EXPENSIVE) |
||||
declined_too_expensive.short_description = "Declined too expensive" |
||||
|
||||
def declined_use_something_else(modeladmin, request, queryset): |
||||
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.USE_OTHER_PRODUCT) |
||||
declined_use_something_else.short_description = "Declined use something else" |
||||
|
||||
def declined_android_user(modeladmin, request, queryset): |
||||
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.USE_ANDROID) |
||||
declined_android_user.short_description = "Declined use Android" |
||||
|
||||
def mark_as_have_account(modeladmin, request, queryset): |
||||
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.HAVE_CREATED_ACCOUNT, None) |
||||
mark_as_have_account.short_description = "Mark as having an account" |
||||
|
||||
def mark_as_not_concerned(modeladmin, request, queryset): |
||||
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.NOT_CONCERNED, None) |
||||
mark_as_not_concerned.short_description = "Mark as not concerned" |
||||
|
||||
def create_default_activity_for_prospect(modeladmin, request, queryset, type, status, reason): |
||||
for prospect in queryset: |
||||
activity = Activity.objects.create( |
||||
type=type, |
||||
status=status, |
||||
declination_reason=reason, |
||||
related_user = request.user |
||||
) |
||||
activity.prospects.add(prospect) |
||||
|
||||
modeladmin.message_user( |
||||
request, |
||||
f'{queryset.count()} prospects were marked as {status}.' |
||||
) |
||||
|
||||
def create_activity_for_prospect(modeladmin, request, queryset): |
||||
# Only allow single selection |
||||
if queryset.count() != 1: |
||||
messages.error(request, "Please select exactly one prospect.") |
||||
return |
||||
|
||||
prospect = queryset.first() |
||||
|
||||
# Build the URL with pre-populated fields |
||||
url = reverse('admin:biz_activity_add') |
||||
url += f'?prospect={prospect.id}' |
||||
return redirect(url) |
||||
create_activity_for_prospect.short_description = "Create activity" |
||||
|
||||
@admin.register(Prospect) |
||||
class ProspectAdmin(SyncedObjectAdmin): |
||||
readonly_fields = ['related_activities', 'entity_names', 'current_status', 'id'] |
||||
fieldsets = [ |
||||
(None, { |
||||
'fields': ['related_activities', 'id', 'entity_names', 'first_name', 'last_name', 'email', 'phone', 'contact_again', 'official_user', 'name_unsure', 'entities', 'related_user'] |
||||
}), |
||||
] |
||||
list_display = ('first_name', 'last_name', 'entity_names', 'phone', 'last_update_date', 'current_status', 'contact_again') |
||||
|
||||
list_filter = (ContactAgainFilter, ProspectStatusFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter, 'creation_date', StaffUserFilter, 'source', ProspectProfileFilter) |
||||
search_fields = ('first_name', 'last_name', 'email', 'phone') |
||||
date_hierarchy = 'creation_date' |
||||
change_list_template = "admin/biz/prospect/change_list.html" |
||||
ordering = ['-last_update'] |
||||
filter_horizontal = ['entities'] |
||||
actions = ['send_email', create_activity_for_prospect, mark_as_inbound, contacted_by_sms, mark_as_should_test, mark_as_testing, mark_as_customer, mark_as_have_account, declined_too_expensive, declined_use_something_else, declined_android_user, mark_as_not_concerned] |
||||
autocomplete_fields = ['official_user', 'related_user'] |
||||
|
||||
def save_model(self, request, obj, form, change): |
||||
if obj.related_user is None: |
||||
obj.related_user = request.user |
||||
super().save_model(request, obj, form, change) |
||||
|
||||
def last_update_date(self, obj): |
||||
return obj.last_update.date() if obj.last_update else None |
||||
last_update_date.short_description = 'Last Update' |
||||
last_update_date.admin_order_field = 'last_update' |
||||
|
||||
def related_activities(self, obj): |
||||
activities = obj.activities.all() |
||||
if activities: |
||||
activity_links = [] |
||||
for activity in activities: |
||||
url = f"/kingdom/biz/activity/{activity.id}/change/" |
||||
activity_links.append(f'<a href="{url}">{activity.html_desc()}</a>') |
||||
return format_html('<br>'.join(activity_links)) |
||||
return "No events" |
||||
related_activities.short_description = "Related Activities" |
||||
|
||||
def get_urls(self): |
||||
urls = super().get_urls() |
||||
custom_urls = [ |
||||
path('dashboard/', self.admin_site.admin_view(self.dashboard), name='biz_dashboard'), |
||||
path('import_file/', self.admin_site.admin_view(self.import_file), name='import_file'), |
||||
path('import_app_users/', self.admin_site.admin_view(self.import_app_users), name='import_app_users'), |
||||
path('cleanup/', self.admin_site.admin_view(self.cleanup), name='cleanup'), |
||||
] |
||||
return custom_urls + urls |
||||
|
||||
def dashboard(self, request): |
||||
""" |
||||
Dashboard view showing prospects organized by status columns |
||||
""" |
||||
# Get filter parameter - if 'my' is true, filter by current user |
||||
filter_my = request.GET.get('my', 'false') == 'true' |
||||
|
||||
# Base queryset |
||||
base_queryset = Prospect.objects.select_related().prefetch_related('entities', 'activities') |
||||
|
||||
# Apply user filter if requested |
||||
if filter_my: |
||||
base_queryset = base_queryset.filter(related_user=request.user) |
||||
|
||||
# Helper function to get prospects by status |
||||
def get_prospects_by_status(statuses): |
||||
# Get the latest activity status for each prospect |
||||
latest_activity = Activity.objects.filter( |
||||
prospects=OuterRef('pk'), |
||||
status__isnull=False |
||||
).order_by('-creation_date') |
||||
|
||||
prospects = base_queryset.annotate( |
||||
latest_status=Subquery(latest_activity.values('status')[:1]) |
||||
).filter( |
||||
latest_status__in=statuses |
||||
).order_by('last_update') |
||||
|
||||
return prospects |
||||
|
||||
# Get prospects for each column |
||||
should_test_prospects = get_prospects_by_status([Status.SHOULD_TEST]) |
||||
testing_prospects = get_prospects_by_status([Status.TESTING]) |
||||
responded_prospects = get_prospects_by_status([Status.RESPONDED]) |
||||
others_prospects = get_prospects_by_status([Status.INBOUND, Status.SHOULD_BUY]) |
||||
|
||||
# Get prospects with contact_again date set, sorted by oldest first |
||||
contact_again_prospects = base_queryset.filter( |
||||
contact_again__isnull=False |
||||
).order_by('contact_again') |
||||
|
||||
context = { |
||||
'title': 'CRM Dashboard', |
||||
'should_test_prospects': should_test_prospects, |
||||
'testing_prospects': testing_prospects, |
||||
'responded_prospects': responded_prospects, |
||||
'others_prospects': others_prospects, |
||||
'contact_again_prospects': contact_again_prospects, |
||||
'filter_my': filter_my, |
||||
'opts': self.model._meta, |
||||
'has_view_permission': self.has_view_permission(request), |
||||
} |
||||
|
||||
return render(request, 'admin/biz/dashboard.html', context) |
||||
|
||||
def cleanup(self, request): |
||||
Entity.objects.all().delete() |
||||
Prospect.objects.all().delete() |
||||
Activity.objects.all().delete() |
||||
|
||||
messages.success(request, 'cleanup biz objects') |
||||
return redirect('admin:biz_prospect_changelist') |
||||
|
||||
def import_app_users(self, request): |
||||
users = CustomUser.objects.filter(origin=UserOrigin.APP) |
||||
|
||||
created_count = 0 |
||||
for user in users: |
||||
is_customer = user.purchases.count() > 0 |
||||
entity_name = user.latest_event_club_name() |
||||
|
||||
prospect, prospect_created = Prospect.objects.get_or_create( |
||||
email=user.email, |
||||
defaults={ |
||||
'first_name': user.first_name, |
||||
'last_name': user.last_name, |
||||
'phone': user.phone, |
||||
'name_unsure': False, |
||||
'official_user': user, |
||||
'source': 'App', |
||||
} |
||||
) |
||||
if entity_name: |
||||
entity, entity_created = Entity.objects.get_or_create( |
||||
name=entity_name, |
||||
defaults={'name': entity_name} |
||||
) |
||||
prospect.entities.add(entity) |
||||
|
||||
if is_customer: |
||||
activity = Activity.objects.create( |
||||
status=Status.CUSTOMER, |
||||
) |
||||
activity.prospects.add(prospect) |
||||
if prospect_created: |
||||
created_count += 1 |
||||
|
||||
messages.success(request, f'Imported {created_count} app users into prospects') |
||||
return redirect('admin:biz_prospect_changelist') |
||||
|
||||
def import_file(self, request): |
||||
""" |
||||
Handle file import - displays form and processes file upload |
||||
""" |
||||
if request.method == 'POST': |
||||
form = FileImportForm(request.POST, request.FILES) |
||||
if form.is_valid(): |
||||
# Call the import_csv method with the uploaded file |
||||
try: |
||||
result = self.import_csv(form.cleaned_data['file'], form.cleaned_data['source']) |
||||
messages.success(request, f'File imported successfully: {result}') |
||||
return redirect('admin:biz_prospect_changelist') |
||||
except Exception as e: |
||||
messages.error(request, f'Error importing file: {str(e)}') |
||||
else: |
||||
messages.error(request, 'Please correct the errors below.') |
||||
else: |
||||
form = FileImportForm() |
||||
|
||||
context = { |
||||
'form': form, |
||||
'title': 'Import File', |
||||
'app_label': self.model._meta.app_label, |
||||
'opts': self.model._meta, |
||||
'has_change_permission': self.has_change_permission(request), |
||||
} |
||||
return render(request, 'admin/biz/prospect/import_file.html', context) |
||||
|
||||
def import_csv(self, file, source): |
||||
""" |
||||
Process the uploaded CSV file |
||||
CSV format: entity_name,last_name,first_name,email,phone,attachment_text,status,related_user |
||||
""" |
||||
try: |
||||
# Read the file content |
||||
file_content = file.read().decode('utf-8') |
||||
csv_reader = csv.reader(io.StringIO(file_content), delimiter=';') |
||||
created_prospects = 0 |
||||
updated_prospects = 0 |
||||
created_entities = 0 |
||||
created_events = 0 |
||||
|
||||
for row in csv_reader: |
||||
print(f'>>> row size is {len(row)}') |
||||
|
||||
if len(row) < 5: |
||||
print(f'>>> WARNING: row size is {len(row)}: {row}') |
||||
continue # Skip rows that don't have enough columns |
||||
|
||||
entity_name = row[0].strip() |
||||
last_name = row[1].strip() |
||||
first_name = row[2].strip() |
||||
email = row[3].strip() |
||||
phone = row[4].strip() if row[4].strip() else None |
||||
if phone and not phone.startswith('0'): |
||||
phone = '0' + phone |
||||
# attachment_text = row[5].strip() if row[5].strip() else None |
||||
# status_text = row[6].strip() if row[6].strip() else None |
||||
# related_user_name = row[7].strip() if row[7].strip() else None |
||||
|
||||
# Create or get Entity |
||||
entity = None |
||||
if entity_name: |
||||
entity, entity_created = Entity.objects.get_or_create( |
||||
name=entity_name, |
||||
defaults={'name': entity_name} |
||||
) |
||||
if entity_created: |
||||
created_entities += 1 |
||||
|
||||
# Get related user if provided |
||||
# related_user = None |
||||
# if related_user_name: |
||||
# try: |
||||
# related_user = User.objects.get(username=related_user_name) |
||||
# except User.DoesNotExist: |
||||
# # Try to find by first name if username doesn't exist |
||||
# related_user = User.objects.filter(first_name__icontains=related_user_name).first() |
||||
|
||||
# Create or update Prospect |
||||
prospect, prospect_created = Prospect.objects.get_or_create( |
||||
email=email, |
||||
defaults={ |
||||
'first_name': first_name, |
||||
'last_name': last_name, |
||||
'phone': phone, |
||||
'name_unsure': False, |
||||
'source': source, |
||||
} |
||||
) |
||||
|
||||
if prospect_created: |
||||
created_prospects += 1 |
||||
# else: |
||||
# # Check if names are different and mark as name_unsure |
||||
# if (prospect.first_name != first_name or prospect.last_name != last_name): |
||||
# prospect.name_unsure = True |
||||
# # Update related_user if provided |
||||
# if related_user: |
||||
# prospect.related_user = related_user |
||||
# prospect.save() |
||||
# updated_prospects += 1 |
||||
|
||||
# Associate entity with prospect |
||||
if entity: |
||||
prospect.entities.add(entity) |
||||
|
||||
# Create Event if attachment_text or status is provided |
||||
# if attachment_text or status_text: |
||||
# # Map status text to Status enum |
||||
# status_value = None |
||||
# declination_reason = None |
||||
# if status_text: |
||||
# if 'CONTACTED' in status_text: |
||||
# status_value = Status.CONTACTED |
||||
# elif 'RESPONDED' in status_text: |
||||
# status_value = Status.RESPONDED |
||||
# elif 'SHOULD_TEST' in status_text: |
||||
# status_value = Status.SHOULD_TEST |
||||
# elif 'CUSTOMER' in status_text: |
||||
# status_value = Status.CUSTOMER |
||||
# elif 'TESTING' in status_text: |
||||
# status_value = Status.TESTING |
||||
# elif 'LOST' in status_text: |
||||
# status_value = Status.LOST |
||||
# elif 'DECLINED_TOO_EXPENSIVE' in status_text: |
||||
# status_value = Status.DECLINED |
||||
# declination_reason = DeclinationReason.TOO_EXPENSIVE |
||||
# elif 'USE_OTHER_PRODUCT' in status_text: |
||||
# status_value = Status.DECLINED |
||||
# declination_reason = DeclinationReason.USE_OTHER_PRODUCT |
||||
# elif 'USE_ANDROID' in status_text: |
||||
# status_value = Status.DECLINED |
||||
# declination_reason = DeclinationReason.USE_ANDROID |
||||
# elif 'NOK' in status_text: |
||||
# status_value = Status.DECLINED |
||||
# declination_reason = DeclinationReason.UNKNOWN |
||||
# elif 'DECLINED_UNRELATED' in status_text: |
||||
# status_value = Status.DECLINED_UNRELATED |
||||
|
||||
# activity = Activity.objects.create( |
||||
# type=ActivityType.SMS, |
||||
# attachment_text=attachment_text, |
||||
# status=status_value, |
||||
# declination_reason=declination_reason, |
||||
# description=f"Imported from CSV - Status: {status_text}" if status_text else "Imported from CSV" |
||||
# ) |
||||
# activity.prospects.add(prospect) |
||||
# created_events += 1 |
||||
|
||||
result = f"Successfully imported: {created_prospects} new prospects, {updated_prospects} updated prospects, {created_entities} new entities, {created_events} new events" |
||||
return result |
||||
|
||||
except Exception as e: |
||||
raise Exception(f"Error processing CSV file: {str(e)}") |
||||
|
||||
def send_email(self, request, queryset): |
||||
|
||||
logger.info('send_email to prospects form initiated...') |
||||
|
||||
if 'apply' in request.POST: |
||||
form = EmailTemplateSelectionForm(request.POST) |
||||
if form.is_valid(): |
||||
email_template = form.cleaned_data['email_template'] |
||||
|
||||
sent_count, failed_count = self.process_selected_items_with_template(request, queryset, email_template) |
||||
|
||||
if failed_count > 0: |
||||
self.message_user(request, f"Email sent to {sent_count} prospects, {failed_count} failed using the '{email_template.name}' template.", messages.WARNING) |
||||
else: |
||||
self.message_user(request, f"Email sent to {sent_count} prospects using the '{email_template.name}' template.", messages.SUCCESS) |
||||
return HttpResponseRedirect(request.get_full_path()) |
||||
else: |
||||
form = EmailTemplateSelectionForm() |
||||
|
||||
return render(request, 'admin/biz/select_email_template.html', { |
||||
'prospects': queryset, |
||||
'form': form, |
||||
'title': 'Send Email to Prospects' |
||||
}) |
||||
send_email.short_description = "Send email" |
||||
|
||||
def process_selected_items_with_template(self, request, queryset, email_template): |
||||
|
||||
sent_count = 0 |
||||
error_emails = [] |
||||
all_emails = [] |
||||
|
||||
logger.info(f'Sending email to {queryset.count()} users...') |
||||
|
||||
for prospect in queryset: |
||||
mail_body = email_template.body.replace( |
||||
'{{name}}', |
||||
f' {prospect.first_name}' if prospect.first_name and len(prospect.first_name) > 0 else '' |
||||
) |
||||
# mail_body = email_template.body.replace('{{name}}', prospect.first_name) |
||||
all_emails.append(prospect.email) |
||||
|
||||
try: |
||||
send_mail( |
||||
email_template.subject, |
||||
mail_body, |
||||
request.user.email, |
||||
[prospect.email], |
||||
fail_silently=False, |
||||
) |
||||
sent_count += 1 |
||||
|
||||
activity = Activity.objects.create( |
||||
type=ActivityType.MAIL, |
||||
status=Status.CONTACTED, |
||||
description=f"Email sent: {email_template.subject}" |
||||
) |
||||
activity.prospects.add(prospect) |
||||
except Exception as e: |
||||
error_emails.append(prospect.email) |
||||
logger.error(f'Failed to send email to {prospect.email}: {str(e)}') |
||||
|
||||
time.sleep(1) |
||||
|
||||
if error_emails: |
||||
logger.error(f'Failed to send emails to: {error_emails}') |
||||
|
||||
return sent_count, len(error_emails) |
||||
|
||||
@admin.register(ProspectGroup) |
||||
class ProspectGroupAdmin(SyncedObjectAdmin): |
||||
list_display = ('name', 'user_count') |
||||
date_hierarchy = 'creation_date' |
||||
raw_id_fields = ['related_user'] |
||||
|
||||
@admin.register(Activity) |
||||
class ActivityAdmin(SyncedObjectAdmin): |
||||
# raw_id_fields = ['prospects'] |
||||
list_display = ('prospect_names', 'last_update', 'status', 'type', 'description', 'attachment_text', ) |
||||
list_filter = ('status', 'type') |
||||
search_fields = ('attachment_text',) |
||||
date_hierarchy = 'last_update' |
||||
autocomplete_fields = ['prospects', 'related_user'] |
||||
|
||||
def get_form(self, request, obj=None, **kwargs): |
||||
form = super().get_form(request, obj, **kwargs) |
||||
|
||||
# Pre-populate fields from URL parameters |
||||
if 'prospect' in request.GET: |
||||
try: |
||||
prospect_id = request.GET['prospect'] |
||||
prospect = Prospect.objects.get(id=prospect_id) |
||||
form.base_fields['prospects'].initial = [prospect] |
||||
form.base_fields['related_user'].initial = request.user |
||||
|
||||
# You can set other fields based on the prospect |
||||
# form.base_fields['title'].initial = f"Event for {prospect.}" |
||||
# form.base_fields['status'].initial = 'pending' |
||||
|
||||
except (Prospect.DoesNotExist, ValueError): |
||||
pass |
||||
|
||||
return form |
||||
|
||||
def save_model(self, request, obj, form, change): |
||||
if obj.related_user is None: |
||||
obj.related_user = request.user |
||||
super().save_model(request, obj, form, change) |
||||
|
||||
def get_event_display(self, obj): |
||||
return str(obj) |
||||
get_event_display.short_description = 'Activity' |
||||
@ -1,70 +0,0 @@ |
||||
from django.urls import path |
||||
from django.http import HttpResponse |
||||
from tournaments.models import CustomUser |
||||
from tournaments.models.enums import UserOrigin |
||||
from django.core.mail import send_mail |
||||
|
||||
import time |
||||
|
||||
def users_list(with_tournaments): |
||||
return CustomUser.objects.filter(origin=UserOrigin.APP).exclude(purchase__isnull=False).filter(events__isnull=with_tournaments) |
||||
|
||||
def email_users_with_tournaments_count(request): |
||||
users = users_list(False) |
||||
emails = [user.email for user in users] |
||||
return HttpResponse(f'users = {len(users)}, \n\nemails = {emails}') |
||||
|
||||
def email_users_count(request): |
||||
users = users_list(True) |
||||
emails = [user.email for user in users] |
||||
return HttpResponse(f'users = {len(users)}, \n\nemails = {emails}') |
||||
|
||||
def email_users_view(request): |
||||
return email_users(request, users_list(True), 0) |
||||
|
||||
def email_users_with_tournaments(request): |
||||
return email_users(request, users_list(False), 1) |
||||
|
||||
def email_users(request, users, template_index): |
||||
|
||||
users = users_list(True) |
||||
|
||||
subject = 'check Padel Club' |
||||
from_email = 'laurent@padelclub.app' |
||||
|
||||
sent_count = 0 |
||||
error_emails = [] |
||||
all_emails = [] |
||||
|
||||
for user in users: |
||||
mail_body = template(user, template_index) # f'Bonjour {user.first_name}, cool la vie ?' |
||||
all_emails.append(user.email) |
||||
|
||||
try: |
||||
send_mail( |
||||
subject, |
||||
mail_body, |
||||
from_email, |
||||
[user.email], |
||||
fail_silently=False, |
||||
) |
||||
sent_count += 1 |
||||
except Exception as e: |
||||
error_emails.append(user.email) |
||||
|
||||
time.sleep(1) |
||||
|
||||
return HttpResponse(f'users = {len(users)}, sent = {sent_count}, errors = {len(error_emails)}, \n\nemails = {all_emails}, \n\nerror emails = {error_emails}') |
||||
|
||||
def template(user, index): |
||||
if index == 0: |
||||
return f'Bonjour {user.first_name}, \n\n' |
||||
else: |
||||
return f'Bonjour {user.first_name}, \n\nJe te remercie d\'avoir téléchargé Padel Club. J\'ai pu voir que tu avais créé quelques tournois mais sans aller plus loin, est-ce que tu pourrais me dire ce qui t\'as freiné ?\n\nLaurent Morvillier' |
||||
|
||||
urlpatterns = [ |
||||
path('email_users/', email_users_view, name='biz_email_users'), |
||||
path('email_users_count/', email_users_count, name='biz_email_count'), |
||||
path('email_users_with_tournaments_count/', email_users_with_tournaments_count, name='biz_email_with_tournaments_count'), |
||||
path('email_users_with_tournaments/', email_users_with_tournaments, name='email_users_with_tournaments'), |
||||
] |
||||
@ -1,5 +0,0 @@ |
||||
from django.apps import AppConfig |
||||
|
||||
class BizConfig(AppConfig): |
||||
default_auto_field = 'django.db.models.BigAutoField' |
||||
name = 'biz' |
||||
@ -1,163 +0,0 @@ |
||||
from xml.dom import Node |
||||
import django_filters |
||||
from django.db.models import Max, F, Q |
||||
from django.contrib.auth import get_user_model |
||||
from django.contrib import admin |
||||
from django.utils import timezone |
||||
|
||||
from dateutil.relativedelta import relativedelta |
||||
|
||||
from .models import Activity, Prospect, Status, DeclinationReason, ProspectGroup |
||||
|
||||
User = get_user_model() |
||||
|
||||
class ProspectFilter(django_filters.FilterSet): |
||||
zip_code = django_filters.CharFilter(lookup_expr='istartswith', label='Code postal') |
||||
activities = django_filters.ModelMultipleChoiceFilter( |
||||
queryset=Activity.objects.all(), |
||||
field_name='activities', |
||||
) |
||||
city = django_filters.CharFilter(lookup_expr='icontains', label='Ville') |
||||
name = django_filters.CharFilter(method='filter_name', label='Nom') |
||||
|
||||
def filter_name(self, queryset, name, value): |
||||
return queryset.filter( |
||||
Q(first_name__icontains=value) | Q(last_name__icontains=value) | Q(entity_name__icontains=value) |
||||
) |
||||
|
||||
class Meta: |
||||
model = Prospect |
||||
fields = ['name', 'city', 'activities', 'zip_code'] |
||||
|
||||
class StaffUserFilter(admin.SimpleListFilter): |
||||
title = 'staff user' |
||||
parameter_name = 'user' |
||||
|
||||
def lookups(self, request, model_admin): |
||||
staff_users = User.objects.filter(is_staff=True) |
||||
return [(user.id, user.username) for user in staff_users] |
||||
|
||||
def queryset(self, request, queryset): |
||||
# Filter the queryset based on the selected user ID |
||||
if self.value(): |
||||
return queryset.filter(related_user__id=self.value()) |
||||
return queryset |
||||
|
||||
class ProspectProfileFilter(admin.SimpleListFilter): |
||||
title = 'Prospect profiles' # displayed in the admin UI |
||||
parameter_name = 'profile' # URL parameter |
||||
|
||||
def lookups(self, request, model_admin): |
||||
return ( |
||||
('tournament_at_least_1_month_old', 'tournaments > 1 month old'), |
||||
('no_tournaments', 'No tournaments'), |
||||
) |
||||
|
||||
def queryset(self, request, queryset): |
||||
if not self.value(): |
||||
return queryset |
||||
|
||||
two_months_ago = timezone.now().date() - relativedelta(months=2) |
||||
|
||||
if self.value() == 'tournament_at_least_2_month_old': |
||||
return queryset.filter( |
||||
official_user__isnull=False, |
||||
official_user__events__creation_date__lte=two_months_ago |
||||
) |
||||
elif self.value() == 'no_tournaments': |
||||
return queryset.filter( |
||||
official_user__isnull=False, |
||||
official_user__events__isnull=True |
||||
) |
||||
|
||||
class ProspectStatusFilter(admin.SimpleListFilter): |
||||
title = 'Status' |
||||
parameter_name = 'status' |
||||
|
||||
def lookups(self, request, model_admin): |
||||
return [(tag.name, tag.value) for tag in Status] |
||||
|
||||
def queryset(self, request, queryset): |
||||
if self.value() == Status.NONE: |
||||
return queryset.filter(activities__isnull=True) |
||||
elif self.value(): |
||||
prospects_with_status = [] |
||||
for prospect in queryset: |
||||
if prospect.current_status() == self.value(): |
||||
prospects_with_status.append(prospect.id) |
||||
return queryset.filter(id__in=prospects_with_status) |
||||
else: |
||||
return queryset |
||||
|
||||
class ProspectDeclineReasonFilter(admin.SimpleListFilter): |
||||
title = 'Decline reason' |
||||
parameter_name = 'reason' |
||||
|
||||
def lookups(self, request, model_admin): |
||||
return [(tag.name, tag.value) for tag in DeclinationReason] |
||||
|
||||
def queryset(self, request, queryset): |
||||
if self.value(): |
||||
# Get prospects whose most recent activity has the selected status |
||||
return queryset.filter( |
||||
activities__declination_reason=self.value() |
||||
).annotate( |
||||
latest_activity_date=Max('activities__creation_date') |
||||
).filter( |
||||
activities__creation_date=F('latest_activity_date'), |
||||
activities__declination_reason=self.value() |
||||
).distinct() |
||||
else: |
||||
return queryset |
||||
|
||||
class ProspectGroupFilter(admin.SimpleListFilter): |
||||
title = 'ProspectGroup' |
||||
parameter_name = 'prospect_group' |
||||
|
||||
def lookups(self, request, model_admin): |
||||
prospect_groups = ProspectGroup.objects.all().order_by('-creation_date') |
||||
return [(group.id, group.name) for group in prospect_groups] |
||||
|
||||
def queryset(self, request, queryset): |
||||
if self.value(): |
||||
return queryset.filter(prospect_groups__id=self.value()) |
||||
return queryset |
||||
|
||||
class ContactAgainFilter(admin.SimpleListFilter): |
||||
title = 'Contact again' # or whatever you want |
||||
parameter_name = 'contact_again' |
||||
|
||||
def lookups(self, request, model_admin): |
||||
return ( |
||||
('1', 'Should be contacted'), |
||||
# ('0', 'Is null'), |
||||
) |
||||
|
||||
def queryset(self, request, queryset): |
||||
if self.value() == '1': |
||||
return queryset.filter(contact_again__isnull=False) |
||||
# if self.value() == '0': |
||||
# return queryset.filter(my_field__isnull=True) |
||||
return queryset |
||||
|
||||
class PhoneFilter(admin.SimpleListFilter): |
||||
title = 'Phone number' |
||||
parameter_name = 'phone_filter' |
||||
|
||||
def lookups(self, request, model_admin): |
||||
return ( |
||||
('exclude_mobile', 'Exclude mobile (06/07)'), |
||||
('mobile_only', 'Mobile only (06/07)'), |
||||
) |
||||
|
||||
def queryset(self, request, queryset): |
||||
if self.value() == 'exclude_mobile': |
||||
return queryset.exclude( |
||||
Q(phone__startswith='06') | Q(phone__startswith='07') |
||||
) |
||||
elif self.value() == 'mobile_only': |
||||
return queryset.filter( |
||||
Q(phone__startswith='06') | Q(phone__startswith='07') |
||||
) |
||||
|
||||
return queryset |
||||
@ -1,61 +0,0 @@ |
||||
from django import forms |
||||
|
||||
from .models import EmailTemplate |
||||
|
||||
# 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 ProspectForm(forms.ModelForm): |
||||
# class Meta: |
||||
# model = Prospect |
||||
# fields = ['entity_name', 'first_name', 'last_name', 'email', |
||||
# 'phone', 'address', 'zip_code', 'city'] |
||||
|
||||
# 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'}), |
||||
# } |
||||
|
||||
class FileImportForm(forms.Form): |
||||
source = forms.CharField(max_length=200) |
||||
file = forms.FileField( |
||||
label='Select file to import', |
||||
help_text='Choose a file to upload and process', |
||||
widget=forms.FileInput(attrs={'accept': '.csv,.xlsx,.xls,.txt'}) |
||||
) |
||||
|
||||
class CSVImportForm(forms.Form): |
||||
csv_file = forms.FileField() |
||||
|
||||
class EmailTemplateSelectionForm(forms.Form): |
||||
email_template = forms.ModelChoiceField( |
||||
queryset=EmailTemplate.objects.all(), |
||||
empty_label="Select an email template...", |
||||
widget=forms.Select(attrs={'class': 'form-control'}) |
||||
) |
||||
@ -1,103 +0,0 @@ |
||||
# Generated by Django 5.1 on 2025-07-20 10:20 |
||||
|
||||
import django.db.models.deletion |
||||
import django.utils.timezone |
||||
import uuid |
||||
from django.conf import settings |
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
initial = True |
||||
|
||||
dependencies = [ |
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.CreateModel( |
||||
name='Activity', |
||||
fields=[ |
||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||
('data_access_ids', models.JSONField(default=list)), |
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||
('status', models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('DECLINED_UNRELATED', 'Declined without significance')], max_length=50, null=True)), |
||||
('declination_reason', models.CharField(blank=True, choices=[('TOO_EXPENSIVE', 'Too expensive'), ('USE_OTHER_PRODUCT', 'Use other product'), ('USE_ANDROID', 'Use Android'), ('UNKNOWN', 'Unknown')], max_length=50, null=True)), |
||||
('type', models.CharField(blank=True, choices=[('MAIL', 'Mail'), ('SMS', 'SMS'), ('CALL', 'Call'), ('PRESS', 'Press Release'), ('WORD_OF_MOUTH', 'Word of mouth')], max_length=20, null=True)), |
||||
('description', models.TextField(blank=True, null=True)), |
||||
('attachment_text', models.TextField(blank=True, null=True)), |
||||
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||
], |
||||
options={ |
||||
'verbose_name_plural': 'Activities', |
||||
'ordering': ['-creation_date'], |
||||
}, |
||||
), |
||||
migrations.CreateModel( |
||||
name='EmailTemplate', |
||||
fields=[ |
||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||
('data_access_ids', models.JSONField(default=list)), |
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||
('name', models.CharField(max_length=100)), |
||||
('subject', models.CharField(max_length=200)), |
||||
('body', models.TextField(blank=True, null=True)), |
||||
('activities', models.ManyToManyField(blank=True, related_name='email_templates', to='biz.activity')), |
||||
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||
], |
||||
options={ |
||||
'abstract': False, |
||||
}, |
||||
), |
||||
migrations.CreateModel( |
||||
name='Entity', |
||||
fields=[ |
||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||
('data_access_ids', models.JSONField(default=list)), |
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||
('name', models.CharField(blank=True, max_length=200, null=True)), |
||||
('address', models.CharField(blank=True, max_length=200, null=True)), |
||||
('zip_code', models.CharField(blank=True, max_length=20, null=True)), |
||||
('city', models.CharField(blank=True, max_length=500, null=True)), |
||||
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||
('official_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), |
||||
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||
], |
||||
options={ |
||||
'verbose_name_plural': 'Entities', |
||||
}, |
||||
), |
||||
migrations.CreateModel( |
||||
name='Prospect', |
||||
fields=[ |
||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||
('data_access_ids', models.JSONField(default=list)), |
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||
('first_name', models.CharField(blank=True, max_length=200, null=True)), |
||||
('last_name', models.CharField(blank=True, max_length=200, null=True)), |
||||
('email', models.EmailField(max_length=254, unique=True)), |
||||
('phone', models.CharField(blank=True, max_length=25, null=True)), |
||||
('name_unsure', models.BooleanField(default=False)), |
||||
('source', models.CharField(blank=True, max_length=100, null=True)), |
||||
('entities', models.ManyToManyField(blank=True, related_name='prospects', to='biz.entity')), |
||||
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||
('official_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), |
||||
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||
], |
||||
options={ |
||||
'abstract': False, |
||||
}, |
||||
), |
||||
migrations.AddField( |
||||
model_name='activity', |
||||
name='prospects', |
||||
field=models.ManyToManyField(related_name='activities', to='biz.prospect'), |
||||
), |
||||
] |
||||
@ -1,18 +0,0 @@ |
||||
# Generated by Django 5.1 on 2025-07-31 15:56 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('biz', '0001_initial'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='prospect', |
||||
name='email', |
||||
field=models.EmailField(blank=True, max_length=254, null=True, unique=True), |
||||
), |
||||
] |
||||
@ -1,18 +0,0 @@ |
||||
# Generated by Django 5.1 on 2025-08-07 16:51 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('biz', '0002_alter_prospect_email'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='activity', |
||||
name='status', |
||||
field=models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('NOT_CONCERNED', 'Not concerned'), ('SHOULD_BUY', 'Should buy')], max_length=50, null=True), |
||||
), |
||||
] |
||||
@ -1,18 +0,0 @@ |
||||
# Generated by Django 5.1 on 2025-09-04 12:42 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('biz', '0003_alter_activity_status'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='prospect', |
||||
name='contact_again', |
||||
field=models.DateTimeField(blank=True, null=True), |
||||
), |
||||
] |
||||
@ -1,38 +0,0 @@ |
||||
# Generated by Django 5.1 on 2025-09-22 12:34 |
||||
|
||||
import django.db.models.deletion |
||||
import django.utils.timezone |
||||
from django.conf import settings |
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('biz', '0004_prospect_contact_again'), |
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='activity', |
||||
name='status', |
||||
field=models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('NOT_CONCERNED', 'Not concerned'), ('SHOULD_BUY', 'Should buy'), ('HAVE_CREATED_ACCOUNT', 'Have created account')], max_length=50, null=True), |
||||
), |
||||
migrations.CreateModel( |
||||
name='Campaign', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||
('data_access_ids', models.JSONField(default=list)), |
||||
('name', models.CharField(blank=True, max_length=200, null=True)), |
||||
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||
('prospects', models.ManyToManyField(blank=True, related_name='campaigns', to='biz.prospect')), |
||||
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||
], |
||||
options={ |
||||
'abstract': False, |
||||
}, |
||||
), |
||||
] |
||||
@ -1,19 +0,0 @@ |
||||
# Generated by Django 5.1 on 2025-09-22 13:10 |
||||
|
||||
import uuid |
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('biz', '0005_alter_activity_status_campaign'), |
||||
] |
||||
|
||||
operations = [ |
||||
# migrations.AlterField( |
||||
# model_name='campaign', |
||||
# name='id', |
||||
# field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False), |
||||
# ), |
||||
] |
||||
@ -1,37 +0,0 @@ |
||||
# Generated by Django 5.1 on 2025-09-22 14:08 |
||||
|
||||
import django.db.models.deletion |
||||
import django.utils.timezone |
||||
import uuid |
||||
from django.conf import settings |
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('biz', '0006_alter_campaign_id'), |
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.CreateModel( |
||||
name='ProspectGroup', |
||||
fields=[ |
||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||
('data_access_ids', models.JSONField(default=list)), |
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||
('name', models.CharField(blank=True, max_length=200, null=True)), |
||||
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||
('prospects', models.ManyToManyField(blank=True, related_name='prospect_groups', to='biz.prospect')), |
||||
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||
], |
||||
options={ |
||||
'abstract': False, |
||||
}, |
||||
), |
||||
migrations.DeleteModel( |
||||
name='Campaign', |
||||
), |
||||
] |
||||
@ -1,23 +0,0 @@ |
||||
# Generated by Django 5.1 on 2025-10-15 07:46 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('biz', '0007_prospectgroup_delete_campaign'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='activity', |
||||
name='declination_reason', |
||||
field=models.CharField(blank=True, choices=[('TOO_EXPENSIVE', 'Too expensive'), ('USE_OTHER_PRODUCT', 'Use other product'), ('USE_ANDROID', 'Use Android'), ('TOO_FEW_TOURNAMENTS', 'Too few tournaments'), ('NOT_INTERESTED', 'Not interested'), ('UNKNOWN', 'Unknown')], max_length=50, null=True), |
||||
), |
||||
migrations.AlterField( |
||||
model_name='activity', |
||||
name='type', |
||||
field=models.CharField(blank=True, choices=[('MAIL', 'Mail'), ('SMS', 'SMS'), ('CALL', 'Call'), ('PRESS', 'Press Release'), ('WORD_OF_MOUTH', 'Word of mouth'), ('WHATS_APP', 'WhatsApp')], max_length=20, null=True), |
||||
), |
||||
] |
||||
@ -1,6 +0,0 @@ |
||||
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin |
||||
from django.core.exceptions import PermissionDenied |
||||
|
||||
class bizAccessMixin(LoginRequiredMixin, UserPassesTestMixin): |
||||
def test_func(self): |
||||
return self.request.user.groups.filter(name='biz Manager').exists() |
||||
@ -1,221 +0,0 @@ |
||||
from typing import Self |
||||
from django.db import models |
||||
from django.contrib.auth import get_user_model |
||||
|
||||
from django.db.models.signals import m2m_changed |
||||
from django.dispatch import receiver |
||||
from django.utils import timezone |
||||
|
||||
import uuid |
||||
|
||||
from sync.models import BaseModel |
||||
|
||||
User = get_user_model() |
||||
|
||||
class Status(models.TextChoices): |
||||
NONE = 'NONE', 'None' |
||||
INBOUND = 'INBOUND', 'Inbound' |
||||
CONTACTED = 'CONTACTED', 'Contacted' |
||||
RESPONDED = 'RESPONDED', 'Responded' |
||||
SHOULD_TEST = 'SHOULD_TEST', 'Should test' |
||||
TESTING = 'TESTING', 'Testing' |
||||
CUSTOMER = 'CUSTOMER', 'Customer' |
||||
LOST = 'LOST', 'Lost customer' |
||||
DECLINED = 'DECLINED', 'Declined' |
||||
# DECLINED_UNRELATED = 'DECLINED_UNRELATED', 'Declined without significance' |
||||
NOT_CONCERNED = 'NOT_CONCERNED', 'Not concerned' |
||||
SHOULD_BUY = 'SHOULD_BUY', 'Should buy' |
||||
HAVE_CREATED_ACCOUNT = 'HAVE_CREATED_ACCOUNT', 'Have created account' |
||||
|
||||
class DeclinationReason(models.TextChoices): |
||||
TOO_EXPENSIVE = 'TOO_EXPENSIVE', 'Too expensive' |
||||
USE_OTHER_PRODUCT = 'USE_OTHER_PRODUCT', 'Use other product' |
||||
USE_ANDROID = 'USE_ANDROID', 'Use Android' |
||||
TOO_FEW_TOURNAMENTS = 'TOO_FEW_TOURNAMENTS', 'Too few tournaments' |
||||
NOT_INTERESTED = 'NOT_INTERESTED', 'Not interested' |
||||
UNKNOWN = 'UNKNOWN', 'Unknown' |
||||
|
||||
class ActivityType(models.TextChoices): |
||||
MAIL = 'MAIL', 'Mail' |
||||
SMS = 'SMS', 'SMS' |
||||
CALL = 'CALL', 'Call' |
||||
PRESS = 'PRESS', 'Press Release' |
||||
WORD_OF_MOUTH = 'WORD_OF_MOUTH', 'Word of mouth' |
||||
WHATS_APP = 'WHATS_APP', 'WhatsApp' |
||||
|
||||
class Entity(BaseModel): |
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||
name = models.CharField(max_length=200, null=True, blank=True) |
||||
address = models.CharField(max_length=200, null=True, blank=True) |
||||
zip_code = models.CharField(max_length=20, null=True, blank=True) |
||||
city = models.CharField(max_length=500, null=True, blank=True) |
||||
official_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) |
||||
# status = models.IntegerField(default=Status.NONE, choices=Status.choices) |
||||
|
||||
def delete_dependencies(self): |
||||
pass |
||||
|
||||
class Meta: |
||||
verbose_name_plural = "Entities" |
||||
|
||||
def __str__(self): |
||||
return self.name |
||||
|
||||
class Prospect(BaseModel): |
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||
first_name = models.CharField(max_length=200, null=True, blank=True) |
||||
last_name = models.CharField(max_length=200, null=True, blank=True) |
||||
email = models.EmailField(unique=True, null=True, blank=True) |
||||
phone = models.CharField(max_length=25, null=True, blank=True) |
||||
name_unsure = models.BooleanField(default=False) |
||||
official_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) |
||||
|
||||
entities = models.ManyToManyField(Entity, blank=True, related_name='prospects') |
||||
source = models.CharField(max_length=100, null=True, blank=True) |
||||
contact_again = models.DateTimeField(null=True, blank=True) |
||||
|
||||
def delete_dependencies(self): |
||||
pass |
||||
|
||||
# class Meta: |
||||
# permissions = [ |
||||
# ("manage_prospects", "Can manage prospects"), |
||||
# ("view_prospects", "Can view prospects"), |
||||
# ] |
||||
|
||||
def current_status(self): |
||||
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first() |
||||
if last_activity: |
||||
return last_activity.status |
||||
return Status.NONE |
||||
|
||||
def current_activity_type(self): |
||||
last_activity = self.activities.exclude(type=None).order_by('-creation_date').first() |
||||
if last_activity: |
||||
return last_activity.type |
||||
return None |
||||
|
||||
def current_text(self): |
||||
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first() |
||||
if last_activity: |
||||
return last_activity.attachment_text |
||||
return '' |
||||
|
||||
def current_declination_reason(self): |
||||
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first() |
||||
if last_activity: |
||||
return last_activity.declination_reason |
||||
return None |
||||
|
||||
def entity_names(self): |
||||
entity_names = [entity.name for entity in self.entities.all()] |
||||
return " - ".join(entity_names) |
||||
|
||||
def full_name(self): |
||||
if self.first_name and self.last_name: |
||||
return f'{self.first_name} {self.last_name}' |
||||
elif self.first_name: |
||||
return self.first_name |
||||
elif self.last_name: |
||||
return self.last_name |
||||
else: |
||||
return 'no name' |
||||
|
||||
def __str__(self): |
||||
return self.full_name() |
||||
|
||||
class Activity(BaseModel): |
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||
status = models.CharField(max_length=50, choices=Status.choices, null=True, blank=True) |
||||
declination_reason = models.CharField(max_length=50, choices=DeclinationReason.choices, null=True, blank=True) |
||||
type = models.CharField(max_length=20, choices=ActivityType.choices, null=True, blank=True) |
||||
description = models.TextField(null=True, blank=True) |
||||
attachment_text = models.TextField(null=True, blank=True) |
||||
prospects = models.ManyToManyField(Prospect, related_name='activities') |
||||
|
||||
def __str__(self): |
||||
if self.status: |
||||
return self.status |
||||
elif self.type: |
||||
return self.type |
||||
else: |
||||
return f'desc = {self.description}, attachment_text = {self.attachment_text}' |
||||
|
||||
def delete_dependencies(self): |
||||
pass |
||||
|
||||
def save(self, *args, **kwargs): |
||||
super().save(*args, **kwargs) |
||||
# Update last_update for all related prospects when activity is saved |
||||
self.prospects.update(last_update=timezone.now()) |
||||
|
||||
class Meta: |
||||
verbose_name_plural = "Activities" |
||||
ordering = ['-creation_date'] |
||||
|
||||
# def __str__(self): |
||||
# return f"{self.get_type_display()} - {self.creation_date.date()}" |
||||
|
||||
def html_desc(self): |
||||
fields = [field for field in [self.creation_date.strftime("%d/%m/%Y %H:%M"), self.status, self.declination_reason, self.attachment_text, self.description, self.type] if field is not None] |
||||
html = '<table><tr>' |
||||
for field in fields: |
||||
html += f'<td style="padding:0px 5px;">{field}</td>' |
||||
html += '</tr></table>' |
||||
return html |
||||
|
||||
def prospect_names(self): |
||||
prospect_names = [prospect.full_name() for prospect in self.prospects.all()] |
||||
return ", ".join(prospect_names) |
||||
|
||||
@receiver(m2m_changed, sender=Activity.prospects.through) |
||||
def update_prospect_last_update(sender, instance, action, pk_set, **kwargs): |
||||
instance.prospects.update(last_update=timezone.now(),contact_again=None) |
||||
|
||||
class EmailTemplate(BaseModel): |
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||
name = models.CharField(max_length=100) |
||||
subject = models.CharField(max_length=200) |
||||
body = models.TextField(null=True, blank=True) |
||||
activities = models.ManyToManyField(Activity, blank=True, related_name='email_templates') |
||||
|
||||
def __str__(self): |
||||
return self.name |
||||
|
||||
def delete_dependencies(self): |
||||
pass |
||||
|
||||
class ProspectGroup(BaseModel): |
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||
name = models.CharField(max_length=200, null=True, blank=True) |
||||
prospects = models.ManyToManyField(Prospect, blank=True, related_name='prospect_groups') |
||||
|
||||
def user_count(self): |
||||
return self.prospects.count() |
||||
|
||||
def __str__(self): |
||||
return self.name |
||||
|
||||
def delete_dependencies(self): |
||||
pass |
||||
|
||||
# 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'] |
||||
@ -1,43 +0,0 @@ |
||||
# 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 |
||||
@ -1,448 +0,0 @@ |
||||
{% extends "admin/base_site.html" %} |
||||
{% load static %} |
||||
|
||||
{% block extrahead %} |
||||
{{ block.super }} |
||||
<style> |
||||
.dashboard-container { |
||||
padding: 20px; |
||||
} |
||||
|
||||
.filter-switch { |
||||
margin-bottom: 20px; |
||||
padding: 15px; |
||||
background: #f8f8f8; |
||||
border: 1px solid #ddd; |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
.filter-switch label { |
||||
font-weight: bold; |
||||
margin-right: 10px; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
.filter-switch input[type="checkbox"] { |
||||
cursor: pointer; |
||||
width: 18px; |
||||
height: 18px; |
||||
vertical-align: middle; |
||||
} |
||||
|
||||
.status-section { |
||||
margin-bottom: 30px; |
||||
background: white; |
||||
border: 1px solid #ddd; |
||||
border-radius: 4px; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
.status-header { |
||||
background: #417690; |
||||
color: white; |
||||
padding: 12px 15px; |
||||
font-weight: bold; |
||||
font-size: 14px; |
||||
} |
||||
|
||||
.status-header .status-name { |
||||
font-size: 16px; |
||||
margin-right: 10px; |
||||
} |
||||
|
||||
.status-header .count { |
||||
font-size: 13px; |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.prospect-table { |
||||
width: 100%; |
||||
border-collapse: collapse; |
||||
table-layout: fixed; |
||||
} |
||||
|
||||
.prospect-table thead { |
||||
background: #f9f9f9; |
||||
border-bottom: 2px solid #ddd; |
||||
} |
||||
|
||||
.prospect-table thead th { |
||||
padding: 10px 12px; |
||||
text-align: left; |
||||
font-weight: 600; |
||||
font-size: 13px; |
||||
color: #666; |
||||
border-bottom: 1px solid #ddd; |
||||
} |
||||
|
||||
.prospect-table th:nth-child(1), |
||||
.prospect-table td:nth-child(1) { |
||||
width: 225px; |
||||
} |
||||
|
||||
.prospect-table th:nth-child(2), |
||||
.prospect-table td:nth-child(2) { |
||||
width: auto; |
||||
} |
||||
|
||||
.prospect-table th:nth-child(3), |
||||
.prospect-table td:nth-child(3) { |
||||
width: 120px; |
||||
} |
||||
|
||||
.prospect-table th:nth-child(4), |
||||
.prospect-table td:nth-child(4) { |
||||
width: 140px; |
||||
} |
||||
|
||||
.prospect-table th:nth-child(5), |
||||
.prospect-table td:nth-child(5) { |
||||
width: 130px; |
||||
} |
||||
|
||||
.prospect-table th:nth-child(6), |
||||
.prospect-table td:nth-child(6) { |
||||
width: 130px; |
||||
} |
||||
|
||||
.prospect-table th.actions-col, |
||||
.prospect-table td.actions-col { |
||||
width: 80px; |
||||
text-align: center; |
||||
} |
||||
|
||||
.add-activity-btn { |
||||
display: inline-flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
width: 24px; |
||||
height: 24px; |
||||
background: #70bf2b; |
||||
color: white !important; |
||||
text-decoration: none !important; |
||||
border-radius: 50%; |
||||
font-size: 18px; |
||||
font-weight: bold; |
||||
transition: background-color 0.2s; |
||||
} |
||||
|
||||
.add-activity-btn:hover { |
||||
background: #5fa624; |
||||
color: white !important; |
||||
text-decoration: none !important; |
||||
} |
||||
|
||||
.prospect-table tbody tr { |
||||
border-bottom: 1px solid #eee; |
||||
transition: background-color 0.2s; |
||||
} |
||||
|
||||
.prospect-table tbody tr:hover { |
||||
background: #f5f5f5; |
||||
} |
||||
|
||||
.prospect-table tbody td { |
||||
padding: 10px 12px; |
||||
font-size: 13px; |
||||
} |
||||
|
||||
.prospect-table tbody td a { |
||||
color: #417690; |
||||
text-decoration: none; |
||||
} |
||||
|
||||
.prospect-table tbody td a:hover { |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
.prospect-name { |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.prospect-entity { |
||||
color: #666; |
||||
font-style: italic; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
} |
||||
|
||||
.prospect-date { |
||||
color: #666; |
||||
white-space: nowrap; |
||||
} |
||||
|
||||
.prospect-status { |
||||
display: inline-block; |
||||
padding: 3px 8px; |
||||
background: #e8f4f8; |
||||
border-radius: 3px; |
||||
font-size: 11px; |
||||
color: #417690; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.empty-state { |
||||
padding: 40px 15px; |
||||
text-align: center; |
||||
color: #999; |
||||
font-style: italic; |
||||
} |
||||
</style> |
||||
{% endblock %} |
||||
|
||||
{% block content %} |
||||
<div class="dashboard-container"> |
||||
|
||||
<!-- Quick Actions --> |
||||
<div class="quick-actions" style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 12px; padding: 25px; margin-bottom: 20px;"> |
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 15px;"> |
||||
<a href="{% url 'admin:biz_prospect_changelist' %}" |
||||
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;"> |
||||
Prospects |
||||
</a> |
||||
<a href="{% url 'admin:biz_activity_changelist' %}" |
||||
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;"> |
||||
Activities |
||||
</a> |
||||
<a href="{% url 'admin:biz_entity_changelist' %}" |
||||
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;"> |
||||
Entities |
||||
</a> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="filter-switch"> |
||||
<label for="my-prospects-toggle"> |
||||
<input type="checkbox" id="my-prospects-toggle" {% if filter_my %}checked{% endif %}> |
||||
Show only my prospects |
||||
</label> |
||||
</div> |
||||
|
||||
<!-- CONTACT AGAIN Section --> |
||||
<div class="status-section"> |
||||
<div class="status-header"> |
||||
<span class="status-name">CONTACT AGAIN</span> |
||||
<span class="count">({{ contact_again_prospects.count }})</span> |
||||
</div> |
||||
{% if contact_again_prospects %} |
||||
<table class="prospect-table"> |
||||
<thead> |
||||
<tr> |
||||
<th>Name</th> |
||||
<th>Entity</th> |
||||
<th>Phone</th> |
||||
<th>Status</th> |
||||
<th>Contact Again</th> |
||||
<th>Last Update</th> |
||||
<th class="actions-col">Actions</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for prospect in contact_again_prospects %} |
||||
<tr> |
||||
<td> |
||||
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||
</a> |
||||
</td> |
||||
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||
<td>{{ prospect.phone|default:"-" }}</td> |
||||
<td><span class="prospect-status">{{ prospect.current_status }}</span></td> |
||||
<td class="prospect-date">{{ prospect.contact_again|date:"d/m/Y" }}</td> |
||||
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||
<td class="actions-col"> |
||||
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||
</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
{% else %} |
||||
<div class="empty-state">No prospects</div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
<!-- SHOULD_TEST Section --> |
||||
<div class="status-section"> |
||||
<div class="status-header"> |
||||
<span class="status-name">SHOULD TEST</span> |
||||
<span class="count">({{ should_test_prospects.count }})</span> |
||||
</div> |
||||
{% if should_test_prospects %} |
||||
<table class="prospect-table"> |
||||
<thead> |
||||
<tr> |
||||
<th>Name</th> |
||||
<th>Entity</th> |
||||
<th>Phone</th> |
||||
<th>Activity Type</th> |
||||
<th>Last Update</th> |
||||
<th class="actions-col">Actions</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for prospect in should_test_prospects %} |
||||
<tr> |
||||
<td> |
||||
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||
</a> |
||||
</td> |
||||
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||
<td>{{ prospect.phone|default:"-" }}</td> |
||||
<td>{{ prospect.current_activity_type|default:"-" }}</td> |
||||
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||
<td class="actions-col"> |
||||
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||
</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
{% else %} |
||||
<div class="empty-state">No prospects</div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
<!-- TESTING Section --> |
||||
<div class="status-section"> |
||||
<div class="status-header"> |
||||
<span class="status-name">TESTING</span> |
||||
<span class="count">({{ testing_prospects.count }})</span> |
||||
</div> |
||||
{% if testing_prospects %} |
||||
<table class="prospect-table"> |
||||
<thead> |
||||
<tr> |
||||
<th>Name</th> |
||||
<th>Entity</th> |
||||
<th>Phone</th> |
||||
<th>Activity Type</th> |
||||
<th>Last Update</th> |
||||
<th class="actions-col">Actions</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for prospect in testing_prospects %} |
||||
<tr> |
||||
<td> |
||||
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||
</a> |
||||
</td> |
||||
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||
<td>{{ prospect.phone|default:"-" }}</td> |
||||
<td>{{ prospect.current_activity_type|default:"-" }}</td> |
||||
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||
<td class="actions-col"> |
||||
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||
</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
{% else %} |
||||
<div class="empty-state">No prospects</div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
<!-- OTHERS Section --> |
||||
<div class="status-section"> |
||||
<div class="status-header"> |
||||
<span class="status-name">OTHERS</span> |
||||
<span class="count">({{ others_prospects.count }})</span> |
||||
</div> |
||||
{% if others_prospects %} |
||||
<table class="prospect-table"> |
||||
<thead> |
||||
<tr> |
||||
<th>Name</th> |
||||
<th>Entity</th> |
||||
<th>Phone</th> |
||||
<th>Status</th> |
||||
<th>Activity Type</th> |
||||
<th>Last Update</th> |
||||
<th class="actions-col">Actions</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for prospect in others_prospects %} |
||||
<tr> |
||||
<td> |
||||
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||
</a> |
||||
</td> |
||||
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||
<td>{{ prospect.phone|default:"-" }}</td> |
||||
<td><span class="prospect-status">{{ prospect.current_status }}</span></td> |
||||
<td>{{ prospect.current_activity_type|default:"-" }}</td> |
||||
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||
<td class="actions-col"> |
||||
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||
</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
{% else %} |
||||
<div class="empty-state">No prospects</div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
<!-- RESPONDED Section --> |
||||
<div class="status-section"> |
||||
<div class="status-header"> |
||||
<span class="status-name">RESPONDED</span> |
||||
<span class="count">({{ responded_prospects.count }})</span> |
||||
</div> |
||||
{% if responded_prospects %} |
||||
<table class="prospect-table"> |
||||
<thead> |
||||
<tr> |
||||
<th>Name</th> |
||||
<th>Entity</th> |
||||
<th>Phone</th> |
||||
<th>Activity Type</th> |
||||
<th>Last Update</th> |
||||
<th class="actions-col">Actions</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for prospect in responded_prospects %} |
||||
<tr> |
||||
<td> |
||||
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||
</a> |
||||
</td> |
||||
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||
<td>{{ prospect.phone|default:"-" }}</td> |
||||
<td>{{ prospect.current_activity_type|default:"-" }}</td> |
||||
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||
<td class="actions-col"> |
||||
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||
</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
{% else %} |
||||
<div class="empty-state">No prospects</div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
</div> |
||||
|
||||
<script> |
||||
document.getElementById('my-prospects-toggle').addEventListener('change', function(e) { |
||||
const url = new URL(window.location); |
||||
if (e.target.checked) { |
||||
url.searchParams.set('my', 'true'); |
||||
} else { |
||||
url.searchParams.delete('my'); |
||||
} |
||||
window.location.href = url.toString(); |
||||
}); |
||||
</script> |
||||
{% endblock %} |
||||
@ -1,81 +0,0 @@ |
||||
{% extends "admin/base_site.html" %} |
||||
{% load i18n admin_urls static admin_list %} |
||||
|
||||
{% block title %}Email Users{% endblock %} |
||||
|
||||
{% block breadcrumbs %} |
||||
<div class="breadcrumbs"> |
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a> |
||||
› Email Users |
||||
</div> |
||||
{% endblock %} |
||||
|
||||
{% block content %} |
||||
<div class="module filtered"> |
||||
<h2>Filter Users for Email</h2> |
||||
|
||||
<form method="post" action="{% url 'admin:email_users' %}"> |
||||
{% csrf_token %} |
||||
|
||||
<div class="form-row"> |
||||
<div class="field-box"> |
||||
<label for="user_origin">User Origin:</label> |
||||
<select name="user_origin" id="user_origin" class="vTextField"> |
||||
<option value="">All Origins</option> |
||||
{% for choice in user_origin_choices %} |
||||
<option value="{{ choice.0 }}" {% if choice.0 == selected_origin %}selected{% endif %}> |
||||
{{ choice.1 }} |
||||
</option> |
||||
{% endfor %} |
||||
</select> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="form-row"> |
||||
<div class="field-box"> |
||||
<label for="has_purchase"> |
||||
<input type="checkbox" name="has_purchase" id="has_purchase" value="1" |
||||
{% if has_purchase %}checked{% endif %}> |
||||
User has made a purchase |
||||
</label> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="form-row"> |
||||
<input type="submit" value="Filter Users" class="default" name="_filter"> |
||||
</div> |
||||
</form> |
||||
|
||||
{% if filtered_users %} |
||||
<div class="results"> |
||||
<h3>Filtered Users ({{ filtered_users|length }} found)</h3> |
||||
<div class="module"> |
||||
<table cellspacing="0"> |
||||
<thead> |
||||
<tr> |
||||
<th>Email</th> |
||||
<th>Origin</th> |
||||
<th>Has Purchase</th> |
||||
<th>Date Joined</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for user in filtered_users %} |
||||
<tr class="{% cycle 'row1' 'row2' %}"> |
||||
<td>{{ user.email }}</td> |
||||
<td>{{ user.get_origin_display }}</td> |
||||
<td>{{ user.has_purchase|yesno:"Yes,No" }}</td> |
||||
<td>{{ user.date_joined|date:"M d, Y" }}</td> |
||||
</tr> |
||||
{% empty %} |
||||
<tr> |
||||
<td colspan="4">No users found matching criteria.</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
{% endblock %} |
||||
@ -1,11 +0,0 @@ |
||||
{% extends "admin/change_list.html" %} |
||||
|
||||
{% block object-tools-items %} |
||||
{{ block.super }} |
||||
<li> |
||||
<a href="{% url 'admin:biz_dashboard' %}" class="viewlink" style="margin-right: 5px;">Dashboard</a> |
||||
<a href="{% url 'admin:import_file' %}" class="addlink" style="margin-right: 5px;">Import</a> |
||||
<a href="{% url 'admin:import_app_users' %}" class="addlink" style="margin-right: 5px;">Import App Users</a> |
||||
<!--<a href="{% url 'admin:cleanup' %}" class="deletelink" style="margin-right: 5px;">Reset</a>--> |
||||
</li> |
||||
{% endblock %} |
||||
@ -1,53 +0,0 @@ |
||||
<!-- templates/admin/import_file.html --> |
||||
{% extends "admin/base_site.html" %} |
||||
{% load i18n static %} |
||||
|
||||
{% block title %}{% trans 'Import File' %}{% endblock %} |
||||
|
||||
{% block breadcrumbs %} |
||||
<div class="breadcrumbs"> |
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a> |
||||
› {% trans 'Import File' %} |
||||
</div> |
||||
{% endblock %} |
||||
|
||||
{% block content %} |
||||
<div class="module"> |
||||
<form method="post" enctype="multipart/form-data" novalidate> |
||||
{% csrf_token %} |
||||
|
||||
<div class="form-row"> |
||||
<div class="field-box"> |
||||
{{ form.source.label_tag }} |
||||
{{ form.source }} |
||||
</div> |
||||
|
||||
<div class="field-box"> |
||||
{{ form.file.label_tag }} |
||||
{{ form.file }} |
||||
{% if form.file.help_text %} |
||||
<div class="help">{{ form.file.help_text }}</div> |
||||
{% endif %} |
||||
{% if form.file.errors %} |
||||
<ul class="errorlist"> |
||||
{% for error in form.file.errors %} |
||||
<li>{{ error }}</li> |
||||
{% endfor %} |
||||
</ul> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="submit-row"> |
||||
<input type="submit" value="{% trans 'Import File' %}" class="default" /> |
||||
<a href="{% url 'admin:index' %}" class="button cancel-link">{% trans 'Cancel' %}</a> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
|
||||
<div class="module"> |
||||
<h2>{% trans 'Instructions' %}</h2> |
||||
<p>{% trans 'Select a file to import and click "Import File" to process it.' %}</p> |
||||
<p>{% trans 'Supported file formats: CSV, Excel (XLSX, XLS), and Text files.' %}</p> |
||||
</div> |
||||
{% endblock %} |
||||
@ -1,29 +0,0 @@ |
||||
{% extends "admin/base_site.html" %} |
||||
{% load i18n admin_urls %} |
||||
|
||||
{% block content %} |
||||
<div id="content-main"> |
||||
<form action="" method="post"> |
||||
{% csrf_token %} |
||||
<h2>{{ title }}</h2> |
||||
|
||||
<p>You have selected the following prospects:</p> |
||||
<ul> |
||||
{% for prospect in prospects %} |
||||
<li>{{ prospect.name }} ({{ prospect.email }})</li> |
||||
<input type="hidden" name="_selected_action" value="{{ prospect.pk }}" /> |
||||
{% endfor %} |
||||
</ul> |
||||
|
||||
<fieldset class="module aligned"> |
||||
<h2>Select an email template:</h2> |
||||
{{ form.as_p }} |
||||
</fieldset> |
||||
|
||||
<div class="submit-row"> |
||||
<input type="hidden" name="action" value="send_email" /> |
||||
<input type="submit" name="apply" value="Send Email" class="default" /> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
{% endblock %} |
||||
@ -1,58 +0,0 @@ |
||||
{% extends "biz/base.html" %} |
||||
|
||||
{% block content %} |
||||
<div class="container padding-bottom"> |
||||
<div class="grid-x padding-bottom"> |
||||
<div class="cell medium-6 large-6 padding10 bubble"> |
||||
<h1 class="title">Add New Prospect</h1> |
||||
|
||||
<form method="post"> |
||||
{% csrf_token %} |
||||
<div class="form-group"> |
||||
<label for="entity_name">Entité (nom de club...):</label> |
||||
<input type="text" name="entity_name" id="entity_name" /> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="first_name">Prénom:</label> |
||||
<input type="text" name="first_name" id="first_name" /> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="last_name">Nom:</label> |
||||
<input type="text" name="last_name" id="last_name" /> |
||||
</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="phone">Téléphone:</label> |
||||
<input type="text" name="phone" id="phone" /> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="address">Adresse:</label> |
||||
<input type="text" name="address" id="address" /> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="zip_code">Code postal:</label> |
||||
<input type="text" name="zip_code" id="zip_code" /> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="city">Ville:</label> |
||||
<input type="text" name="city" id="city" /> |
||||
</div> |
||||
|
||||
<button type="submit" class="small-button margin-v20"> |
||||
Save Prospect |
||||
</button> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{% endblock %} |
||||
@ -1,73 +0,0 @@ |
||||
<!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 --> |
||||
|
||||
{% block extra_js %}{% endblock %} |
||||
|
||||
</head> |
||||
|
||||
<body class="wrapper"> |
||||
<header> |
||||
<div class="grid-x"> |
||||
<div class="medium-6 large-9 cell topblock padding10 "> |
||||
<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> |
||||
@ -1,34 +0,0 @@ |
||||
{% extends "biz/base.html" %} |
||||
|
||||
{% block content %} |
||||
<div class="container mt-4"> |
||||
<h2>Import Prospects from CSV</h2> |
||||
|
||||
<div class="card"> |
||||
<div class="card-body"> |
||||
<form method="post" enctype="multipart/form-data"> |
||||
{% csrf_token %} |
||||
|
||||
<div class="mb-3"> |
||||
{{ form.as_p }} |
||||
</div> |
||||
|
||||
<div class="alert alert-info"> |
||||
<h5>CSV Format Requirements:</h5> |
||||
<p>The CSV file should contain the following columns in order:</p> |
||||
<ul> |
||||
<li>Column 1: Club Code</li> |
||||
<li>Column 2: Last Name</li> |
||||
<li>Column 3: First Name</li> |
||||
<li>Column 4: Email</li> |
||||
<li>Column 9: ZIP Code</li> |
||||
<li>Column 10: City</li> |
||||
</ul> |
||||
</div> |
||||
|
||||
<button type="submit" class="btn btn-primary">Import CSV</button> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{% endblock %} |
||||
@ -1,27 +0,0 @@ |
||||
{% extends "biz/base.html" %} {% block content %} |
||||
<div class="container"> |
||||
<div class="grid-x padding-bottom"> |
||||
<div class="cell medium-6 large-6 padding10 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 'biz:planned_events' %}" |
||||
class="btn btn-secondary" |
||||
>Cancel</a |
||||
> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
{% endblock %} |
||||
@ -1,16 +0,0 @@ |
||||
<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 'biz:edit_event' event.id %}" class="small-button">Edit</a> |
||||
<!-- {% if event.status == 'PLANNED' %} |
||||
<a href="{% url 'biz:start_event' event.id %}" class="small-button">Start</a> |
||||
{% endif %} --> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
@ -1,65 +0,0 @@ |
||||
{% extends "biz/base.html" %} |
||||
{% load biz_tags %} |
||||
|
||||
|
||||
{% block content %} |
||||
|
||||
{% if request.user|is_biz_manager %} |
||||
|
||||
<div class="d-flex"> |
||||
|
||||
<a href="{% url 'biz:prospect-list' %}" class="small-button margin-v20"> |
||||
Prospects |
||||
</a> |
||||
<a href="{% url 'biz:add-event' %}" class="small-button margin-v20"> |
||||
Ajouter un évènement |
||||
</a> |
||||
<a href="{% url 'biz:add-prospect' %}" class="small-button margin-v20 left-margin"> |
||||
Ajouter un prospect |
||||
</a> |
||||
<a href="{% url 'biz:csv-import' %}" class="small-button margin-v20 left-margin"> |
||||
Import |
||||
</a> |
||||
</div> |
||||
|
||||
<div class="container grid-x padding-bottom"> |
||||
<div class="cell medium-6 large-6 padding10 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 "biz/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 padding10 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 "biz/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 %} |
||||
@ -1,17 +0,0 @@ |
||||
{% extends "biz/base.html" %} |
||||
|
||||
{% block head_title %}{{ first_title }}{% endblock %} |
||||
{% block first_title %}{{ first_title }}{% endblock %} |
||||
{% block second_title %}{{ second_title }}{% endblock %} |
||||
|
||||
{% block content %} |
||||
|
||||
<div class="container padding-bottom bubble"><form method="post"> |
||||
{% csrf_token %} |
||||
{{ form.as_p }} |
||||
<button class="small-button" type="submit"> |
||||
{% if is_edit %}Update{% else %}Add{% endif %} Prospect |
||||
</button> |
||||
</form> |
||||
|
||||
{% endblock %} |
||||
@ -1,81 +0,0 @@ |
||||
{% extends "biz/base.html" %} |
||||
|
||||
{% load static %} |
||||
|
||||
{% block content %} |
||||
<div class="container bubble"> |
||||
<h2>Prospects</h2> |
||||
|
||||
<div class=""> |
||||
<div class=""> |
||||
|
||||
<form method="get" class="filter-form"> |
||||
{% for field in filter.form %} |
||||
<div class="filter-group"> |
||||
<label class="filter-label" for="{{ field.id_for_label }}">{{ field.label }}</label> |
||||
{{ field }} |
||||
</div> |
||||
{% endfor %} |
||||
<div class="filter-buttons"> |
||||
<button type="submit" class="btn btn-primary">Filter</button> |
||||
<a href="{% url 'biz:prospect-list' %}" class="btn btn-secondary">Clear</a> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- <div class="mb-3"> |
||||
<a href="{% url 'biz:csv-import' %}" class="btn btn-success">Import CSV</a> |
||||
<a href="{% url 'biz:send-bulk-email' %}" class="btn btn-primary">Send Email</a> |
||||
</div> --> |
||||
|
||||
<span>{{ filter.qs|length }} résultats</span> |
||||
|
||||
<div class="table-responsive"> |
||||
<table class="table table-striped"> |
||||
<thead> |
||||
<tr> |
||||
<th><input type="checkbox" id="select-all"></th> |
||||
<th>Entité</th> |
||||
<th>Prénom</th> |
||||
<th>Nom</th> |
||||
<th>Email</th> |
||||
<th>Ville</th> |
||||
<th>Statut</th> |
||||
<th>Actions</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for prospect in filter.qs %} |
||||
<tr> |
||||
<td><input type="checkbox" name="selected_prospects" value="{{ prospect.id }}"></td> |
||||
<td>{{ prospect.entity_name }}</td> |
||||
<td>{{ prospect.first_name }}</td> |
||||
<td>{{ prospect.last_name }}</td> |
||||
<td><a href="mailto:{{ prospect.email }}">{{ prospect.email }}</a></td> |
||||
<td>{{ prospect.city }} ({{ prospect.zip_code }})</td> |
||||
<td> |
||||
{% for status in prospect.prospectstatus_set.all %} |
||||
<span class="badge bg-primary">{{ status.status.name }}</span> |
||||
{% endfor %} |
||||
</td> |
||||
<td> |
||||
<a href="{% url 'biz:edit-prospect' prospect.id %}"> |
||||
<button class="btn btn-sm btn-secondary">Edit</button> |
||||
</a> |
||||
|
||||
<a href="{% url 'biz:add-event-for-prospect' prospect.id %}"> |
||||
<button class="btn btn-sm btn-secondary">+ Event</button> |
||||
</a> |
||||
</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
</div> |
||||
{% endblock %} |
||||
|
||||
{% block extra_js %} |
||||
<script src="{% static 'biz/js/prospects.js' %}"></script> |
||||
{% endblock %} |
||||
@ -1,47 +0,0 @@ |
||||
{% extends "biz/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 'biz:prospect-list' %}" class="btn btn-secondary">Cancel</a> |
||||
</form> |
||||
</div> |
||||
{% endblock %} |
||||
@ -1,7 +0,0 @@ |
||||
from django import template |
||||
|
||||
register = template.Library() |
||||
|
||||
@register.filter(name='is_biz_manager') |
||||
def is_biz_manager(user): |
||||
return user.groups.filter(name='biz Manager').exists() |
||||
@ -1,3 +0,0 @@ |
||||
from django.test import TestCase |
||||
|
||||
# Create your tests here. |
||||
@ -1,17 +0,0 @@ |
||||
from django.urls import path |
||||
from . import views |
||||
|
||||
app_name = 'biz' |
||||
|
||||
urlpatterns = [ |
||||
path('', views.EventListView.as_view(), name='planned_events'),path('', views.EventListView.as_view(), name='events'), |
||||
path('events/add/', views.EventCreateView.as_view(), name='add-event'), |
||||
path('events/add/<int:prospect_id>/', views.EventCreateView.as_view(), name='add-event-for-prospect'), |
||||
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('prospect/add/', views.prospect_form, name='add-prospect'), |
||||
path('prospect/<int:pk>/edit/', views.prospect_form, name='edit-prospect'), |
||||
path('prospects/import/', views.CSVImportView.as_view(), name='csv-import'), |
||||
path('email/send/', views.SendBulkEmailView.as_view(), name='send-bulk-email'), |
||||
] |
||||
@ -1,284 +0,0 @@ |
||||
# 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 django.db import IntegrityError |
||||
|
||||
from .models import Event, Prospect, ActivityType |
||||
from .filters import ProspectFilter |
||||
from .forms import CSVImportForm |
||||
|
||||
from .mixins import bizAccessMixin |
||||
|
||||
import csv |
||||
from io import TextIOWrapper |
||||
from datetime import datetime |
||||
|
||||
# @permission_required('biz.view_biz', raise_exception=True) |
||||
# def prospect_form(request, pk=None): |
||||
# # Get the prospect instance if pk is provided (edit mode) |
||||
# prospect = get_object_or_404(Prospect, pk=pk) if pk else None |
||||
|
||||
# if request.method == 'POST': |
||||
# form = ProspectForm(request.POST, instance=prospect) |
||||
# if form.is_valid(): |
||||
# prospect = form.save(commit=False) |
||||
# if not pk: # New prospect |
||||
# prospect.created_by = request.user |
||||
# prospect.modified_by = request.user |
||||
# prospect.save() |
||||
|
||||
# action = 'updated' if pk else 'added' |
||||
# messages.success(request, |
||||
# f'Prospect {prospect.entity_name} has been {action} successfully!') |
||||
# return redirect('biz:events') |
||||
# else: |
||||
# form = ProspectForm(instance=prospect) |
||||
|
||||
# context = { |
||||
# 'form': form, |
||||
# 'is_edit': prospect is not None, |
||||
# 'first_title': prospect.entity_name if prospect else 'Add Prospect', |
||||
# 'second_title': prospect.full_name() if prospect else None |
||||
# } |
||||
# return render(request, 'biz/prospect_form.html', context) |
||||
|
||||
# # @permission_required('biz.view_biz', raise_exception=True) |
||||
# # def add_prospect(request): |
||||
# # if request.method == 'POST': |
||||
# # entity_name = request.POST.get('entity_name') |
||||
# # first_name = request.POST.get('first_name') |
||||
# # last_name = request.POST.get('last_name') |
||||
# # email = request.POST.get('email') |
||||
# # phone = request.POST.get('phone') |
||||
# # address = request.POST.get('address') |
||||
# # zip_code = request.POST.get('zip_code') |
||||
# # city = request.POST.get('city') |
||||
# # # region = request.POST.get('region') |
||||
|
||||
# # try: |
||||
# # prospect = Prospect.objects.create( |
||||
# # entity_name=entity_name, |
||||
# # first_name=first_name, |
||||
# # last_name=last_name, |
||||
# # email=email, |
||||
# # phone=phone, |
||||
# # address=address, |
||||
# # zip_code=zip_code, |
||||
# # city=city, |
||||
# # # region=region, |
||||
# # created_by=request.user, |
||||
# # modified_by=request.user |
||||
# # ) |
||||
# # messages.success(request, f'Prospect {name} has been added successfully!') |
||||
# # return redirect('biz: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, 'biz/add_prospect.html') |
||||
|
||||
# class EventCreateView(bizAccessMixin, CreateView): |
||||
# model = Event |
||||
# form_class = EventForm |
||||
# template_name = 'biz/event_form.html' |
||||
# success_url = reverse_lazy('biz:planned_events') |
||||
|
||||
# def get_initial(self): |
||||
# initial = super().get_initial() |
||||
# prospect_id = self.kwargs.get('prospect_id') |
||||
# if prospect_id: |
||||
# initial['prospects'] = [prospect_id] |
||||
# return initial |
||||
|
||||
# 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(bizAccessMixin, UpdateView): |
||||
# model = Event |
||||
# form_class = EventForm |
||||
# template_name = 'biz/event_form.html' |
||||
# success_url = reverse_lazy('biz: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(bizAccessMixin, 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('biz:setup_email_campaign', kwargs={'event_id': event.id}) |
||||
# ) |
||||
# elif event.type == 'SMS': |
||||
# return HttpResponseRedirect( |
||||
# reverse_lazy('biz:setup_sms_campaign', kwargs={'event_id': event.id}) |
||||
# ) |
||||
# elif event.type == 'PRESS': |
||||
# return HttpResponseRedirect( |
||||
# reverse_lazy('biz:setup_press_release', kwargs={'event_id': event.id}) |
||||
# ) |
||||
|
||||
# messages.success(request, 'Event started successfully!') |
||||
# return HttpResponseRedirect(reverse_lazy('biz:planned_events')) |
||||
|
||||
# class EventListView(bizAccessMixin, ListView): |
||||
# model = Event |
||||
# template_name = 'biz/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(bizAccessMixin, ListView): |
||||
# model = Prospect |
||||
# template_name = 'biz/prospect_list.html' |
||||
# context_object_name = 'prospects' |
||||
# filterset_class = ProspectFilter |
||||
|
||||
# def get_queryset(self): |
||||
# return super().get_queryset().prefetch_related('prospectstatus_set__status') |
||||
|
||||
# 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(bizAccessMixin, FormView): |
||||
# template_name = 'biz/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-sig' # Handle potential BOM in CSV |
||||
# ) |
||||
# reader = csv.reader(csv_file, delimiter=';') # Using semicolon delimiter |
||||
|
||||
# # Skip header if exists |
||||
# next(reader, None) |
||||
|
||||
# created_count = 0 |
||||
# updated_count = 0 |
||||
# error_count = 0 |
||||
|
||||
# for row in reader: |
||||
# try: |
||||
# if len(row) < 10: # Ensure we have enough columns |
||||
# continue |
||||
|
||||
# # Extract data from correct columns |
||||
# entity_name = row[0].strip() |
||||
# last_name = row[1].strip() |
||||
# first_name = row[2].strip() |
||||
# email = row[3].strip() |
||||
# phone = row[4].strip() |
||||
# zip_code = row[8].strip() |
||||
# city = row[9].strip() |
||||
|
||||
# # Try to update existing prospect or create new one |
||||
# prospect, created = Prospect.objects.update_or_create( |
||||
# email=email, # Use email as unique identifier |
||||
# defaults={ |
||||
# 'entity_name': entity_name, |
||||
# 'first_name': first_name, |
||||
# 'last_name': last_name, |
||||
# 'phone': phone, |
||||
# 'zip_code': zip_code, |
||||
# 'city': city, |
||||
# 'modified_by': self.request.user, |
||||
# } |
||||
# ) |
||||
|
||||
# if created: |
||||
# prospect.created_by = self.request.user |
||||
# prospect.save() |
||||
# created_count += 1 |
||||
# else: |
||||
# updated_count += 1 |
||||
|
||||
# except Exception as e: |
||||
# error_count += 1 |
||||
# messages.error( |
||||
# self.request, |
||||
# f"Error processing row with email {email}: {str(e)}" |
||||
# ) |
||||
|
||||
# # Add success message |
||||
# messages.success( |
||||
# self.request, |
||||
# f"Import completed: {created_count} created, {updated_count} updated, {error_count} errors" |
||||
# ) |
||||
|
||||
# return super().form_valid(form) |
||||
|
||||
# class SendBulkEmailView(bizAccessMixin, FormView): |
||||
# template_name = 'biz/send_bulk_email.html' |
||||
# form_class = BulkEmailForm |
||||
# success_url = reverse_lazy('biz: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) |
||||
@ -1 +0,0 @@ |
||||
|
||||
@ -1,54 +1,43 @@ |
||||
|
||||
# Rest Framework configuration |
||||
REST_FRAMEWORK = { |
||||
"DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S.%f%z", |
||||
# Use Django's standard `django.contrib.auth` permissions, |
||||
# or allow read-only access for unauthenticated users. |
||||
"DEFAULT_PERMISSION_CLASSES": [ |
||||
"rest_framework.permissions.IsAuthenticated", |
||||
], |
||||
"DEFAULT_AUTHENTICATION_CLASSES": [ |
||||
"rest_framework.authentication.BasicAuthentication", |
||||
"rest_framework.authentication.TokenAuthentication", |
||||
"rest_framework.authentication.SessionAuthentication", |
||||
'DEFAULT_PERMISSION_CLASSES': [ |
||||
'rest_framework.permissions.IsAuthenticated', |
||||
], |
||||
'DEFAULT_AUTHENTICATION_CLASSES': [ |
||||
'rest_framework.authentication.BasicAuthentication', |
||||
'rest_framework.authentication.TokenAuthentication', |
||||
'rest_framework.authentication.SessionAuthentication', |
||||
] |
||||
} |
||||
|
||||
EMAIL_HOST_USER = "automatic@padelclub.app" |
||||
EMAIL_HOST_PASSWORD = "XLR@Sport@2024" |
||||
DEFAULT_FROM_EMAIL = "Padel Club <automatic@padelclub.app>" |
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" |
||||
EMAIL_HOST = "smtp-xlr.alwaysdata.net" |
||||
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' |
||||
# EMAIL_HOST = 'smtp-xlr.alwaysdata.net' |
||||
# EMAIL_PORT = 587 |
||||
# EMAIL_HOST_USER = 'automatic@padelclub.app' |
||||
# EMAIL_HOST_PASSWORD = 'XLRSport$2024' |
||||
# EMAIL_USE_TLS = True |
||||
# DEFAULT_FROM_EMAIL = 'Padel Club <automatic@padelclub.app>' |
||||
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' |
||||
EMAIL_HOST = 'smtp-xlr.alwaysdata.net' |
||||
EMAIL_PORT = 587 |
||||
EMAIL_HOST_USER = 'xlr@alwaysdata.net' |
||||
EMAIL_HOST_PASSWORD = 'XLRSport$2024' |
||||
EMAIL_USE_TLS = True |
||||
DEFAULT_FROM_EMAIL = 'Padel Club <xlr@alwaysdata.net>' |
||||
|
||||
CACHES = { |
||||
"default": { |
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache", |
||||
'default': { |
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', |
||||
}, |
||||
"qr-code": { |
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache", |
||||
"LOCATION": "qr-code-cache", |
||||
"TIMEOUT": 3600, |
||||
}, |
||||
} |
||||
|
||||
QR_CODE_CACHE_ALIAS = "qr-code" |
||||
|
||||
SYNC_APPS = { |
||||
"sync": {}, |
||||
"tournaments": {"exclude": ["Log", "FailedApiCall", "DeviceToken", "Image"]}, |
||||
# 'biz': {}, |
||||
} |
||||
|
||||
SYNC_MODEL_CHILDREN_SHARING = { |
||||
"Match": ["team_scores", "team_registration", "player_registrations"] |
||||
'qr-code': { |
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', |
||||
'LOCATION': 'qr-code-cache', |
||||
'TIMEOUT': 3600 |
||||
} |
||||
} |
||||
|
||||
STRIPE_CURRENCY = "eur" |
||||
# Add managers who should receive internal emails |
||||
SHOP_MANAGERS = [ |
||||
("Shop Admin", "shop-admin@padelclub.app"), |
||||
# ('Laurent Morvillier', 'laurent@padelclub.app'), |
||||
] |
||||
SHOP_SITE_ROOT_URL = "https://padelclub.app" |
||||
SHOP_SUPPORT_EMAIL = "shop@padelclub.app" |
||||
QR_CODE_CACHE_ALIAS = 'qr-code' |
||||
|
||||
|
unable to load file from head commit
|
@ -1,51 +0,0 @@ |
||||
from Crypto.Cipher import AES |
||||
import base64 |
||||
import os |
||||
from .config_local import CRYPTO_KEY |
||||
|
||||
class EncryptionUtil: |
||||
|
||||
def __init__(self, key): |
||||
# In a real application, store this key securely (e.g., environment variables) |
||||
self.crypto_key = key |
||||
|
||||
def encrypt_aes_gcm(self, plaintext): |
||||
# Decode the base64 encoded key |
||||
key = base64.b64decode(self.crypto_key) |
||||
|
||||
# Generate a random 12-byte nonce |
||||
nonce = os.urandom(12) |
||||
|
||||
# Create the cipher object |
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) |
||||
|
||||
# Encrypt the plaintext |
||||
ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode('utf-8')) |
||||
|
||||
# Combine nonce, ciphertext, and tag |
||||
encrypted_data = nonce + ciphertext + tag |
||||
|
||||
# Encode the result in base64 |
||||
encrypted_base64 = base64.b64encode(encrypted_data).decode('utf-8') |
||||
|
||||
return encrypted_base64 |
||||
|
||||
def decrypt_aes_gcm(self, encrypted_base64): |
||||
# Decode the base64 encoded data and key |
||||
encrypted_data = base64.b64decode(encrypted_base64) |
||||
key = base64.b64decode(self.crypto_key) |
||||
|
||||
# Extract the nonce, tag, and ciphertext from the combined encrypted data |
||||
nonce = encrypted_data[:12] # AES GCM nonce is 12 bytes |
||||
tag = encrypted_data[-16:] # AES GCM tag is 16 bytes |
||||
ciphertext = encrypted_data[12:-16] # Ciphertext is everything in between |
||||
|
||||
# Create the cipher object and decrypt the data |
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) |
||||
decrypted_data = cipher.decrypt_and_verify(ciphertext, tag) |
||||
|
||||
# Convert decrypted bytes to string (assuming UTF-8 encoding) |
||||
decrypted_text = decrypted_data.decode('utf-8') |
||||
return decrypted_text |
||||
|
||||
encryption_util = EncryptionUtil(CRYPTO_KEY) |
||||
@ -1,19 +0,0 @@ |
||||
import requests |
||||
|
||||
DISCORD_FAILED_CALLS_WEBHOOK_URL = 'https://discord.com/api/webhooks/1248191778134163486/sSoTL6cULCElWr2YFwyllsg7IXxHcCx_YMDJA_cUHtVUU4WOfN-5M7drCJuwNBBfAk9a' |
||||
DISCORD_LOGS_WEBHOOK_URL = 'https://discord.com/api/webhooks/1257987637449588736/TtOUwzYgSlQH2d3Ps7SfIKRcFALQVa3hfkC-j9K4_UAcWtsfiw4v8NUPbnX2_ZPOYzuv' |
||||
|
||||
def send_discord_failed_calls_message(message): |
||||
send_discord_message(DISCORD_FAILED_CALLS_WEBHOOK_URL, message) |
||||
|
||||
def send_discord_log_message(message): |
||||
send_discord_message(DISCORD_LOGS_WEBHOOK_URL, message) |
||||
|
||||
def send_discord_message(webhook_url, content): |
||||
try: |
||||
data = { |
||||
"content": content |
||||
} |
||||
requests.post(webhook_url, json=data) |
||||
except Exception as e: |
||||
print(f"Failed to send Discord message: {str(e)}") |
||||
@ -1,414 +0,0 @@ |
||||
from django.contrib import admin |
||||
from django.shortcuts import render, redirect |
||||
from django.utils.html import format_html |
||||
from django.urls import path |
||||
from django.http import HttpResponseRedirect |
||||
from django import forms |
||||
from django.db.models import Sum, Count, Avg |
||||
from datetime import datetime, timedelta |
||||
from django.utils import timezone |
||||
|
||||
from .models import ( |
||||
Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage, |
||||
OrderStatus, ShippingAddress |
||||
) |
||||
|
||||
class ShopAdminSite(admin.AdminSite): |
||||
site_header = "Shop Administration" |
||||
site_title = "Shop Admin Portal" |
||||
index_title = "Welcome to Shop Administration" |
||||
|
||||
def index(self, request, extra_context=None): |
||||
"""Custom admin index view with dashboard""" |
||||
# Calculate order statistics |
||||
order_status_data = [] |
||||
total_orders = Order.objects.count() |
||||
total_revenue = Order.objects.aggregate(Sum('total_price'))['total_price__sum'] or 0 |
||||
|
||||
# Get data for each status |
||||
for status_choice in OrderStatus.choices: |
||||
status_code, status_label = status_choice |
||||
orders_for_status = Order.objects.filter(status=status_code) |
||||
count = orders_for_status.count() |
||||
total_amount = orders_for_status.aggregate(Sum('total_price'))['total_price__sum'] or 0 |
||||
avg_order_value = orders_for_status.aggregate(Avg('total_price'))['total_price__avg'] or 0 |
||||
percentage = (count / total_orders * 100) if total_orders > 0 else 0 |
||||
|
||||
order_status_data.append({ |
||||
'status': status_code, |
||||
'label': status_label, |
||||
'count': count, |
||||
'total_amount': total_amount, |
||||
'avg_order_value': avg_order_value, |
||||
'percentage': percentage |
||||
}) |
||||
|
||||
# Recent activity calculations |
||||
now = timezone.now() |
||||
today = now.date() |
||||
week_ago = today - timedelta(days=7) |
||||
month_ago = today - timedelta(days=30) |
||||
|
||||
orders_today = Order.objects.filter(date_ordered__date=today).count() |
||||
orders_this_week = Order.objects.filter(date_ordered__date__gte=week_ago).count() |
||||
orders_this_month = Order.objects.filter(date_ordered__date__gte=month_ago).count() |
||||
orders_to_prepare = Order.objects.filter(status=OrderStatus.PAID).count() |
||||
|
||||
extra_context = extra_context or {} |
||||
extra_context.update({ |
||||
'order_status_data': order_status_data, |
||||
'total_orders': total_orders, |
||||
'total_revenue': total_revenue, |
||||
'orders_today': orders_today, |
||||
'orders_this_week': orders_this_week, |
||||
'orders_this_month': orders_this_month, |
||||
'orders_to_prepare': orders_to_prepare, |
||||
}) |
||||
|
||||
return render(request, 'admin/shop/dashboard.html', extra_context) |
||||
|
||||
# Create an instance of the custom admin site |
||||
shop_admin_site = ShopAdminSite(name='shop_admin') |
||||
|
||||
@admin.register(Product) |
||||
class ProductAdmin(admin.ModelAdmin): |
||||
list_display = ("title", "ordering_value", "price", "cut") |
||||
search_fields = ["title", "description"] # Enable search for autocomplete |
||||
|
||||
@admin.register(Color) |
||||
class ColorAdmin(admin.ModelAdmin): |
||||
list_display = ("color_preview", "name", "ordering", "colorHex", "secondary_hex_color") |
||||
list_editable = ("ordering",) |
||||
ordering = ["ordering"] |
||||
search_fields = ["name"] |
||||
list_per_page = 20 |
||||
|
||||
def color_preview(self, obj): |
||||
if obj.secondary_hex_color: |
||||
return format_html( |
||||
'<div style="background-image: linear-gradient(to right, {} 50%, {} 50%); ' |
||||
'width: 60px; height: 30px; border-radius: 15px; border: 1px solid #ddd;"></div>', |
||||
obj.colorHex, obj.secondary_hex_color |
||||
) |
||||
return format_html( |
||||
'<div style="background-color: {}; width: 60px; height: 30px; ' |
||||
'border-radius: 15px; border: 1px solid #ddd;"></div>', |
||||
obj.colorHex |
||||
) |
||||
|
||||
@admin.register(Size) |
||||
class SizeAdmin(admin.ModelAdmin): |
||||
list_display = ("name",) |
||||
|
||||
class OrderItemInline(admin.TabularInline): |
||||
model = OrderItem |
||||
extra = 1 # Show one extra row for adding new items |
||||
autocomplete_fields = ['product'] # Enable product search |
||||
fields = ('product', 'quantity', 'color', 'size', 'price') |
||||
|
||||
@admin.register(OrderItem) |
||||
class OrderItemAdmin(admin.ModelAdmin): |
||||
list_display = ('order', 'product', 'quantity', 'color', 'size', 'price', 'get_total_price') |
||||
list_filter = ('product', 'color', 'size', 'order__status') |
||||
search_fields = ('order__id', 'product__title', 'order__user__email', 'order__guest_user__email') |
||||
autocomplete_fields = ['order', 'product'] |
||||
list_editable = ('quantity', 'price') |
||||
|
||||
def get_total_price(self, obj): |
||||
return obj.get_total_price() |
||||
get_total_price.short_description = 'Total Price' |
||||
get_total_price.admin_order_field = 'price' # Allows column to be sortable |
||||
|
||||
@admin.register(ShippingAddress) |
||||
class ShippingAddressAdmin(admin.ModelAdmin): |
||||
list_display = ('street_address', 'city', 'postal_code', 'country') |
||||
search_fields = ('street_address', 'city', 'postal_code', 'country') |
||||
|
||||
class ChangeOrderStatusForm(forms.Form): |
||||
_selected_action = forms.CharField(widget=forms.MultipleHiddenInput) |
||||
status = forms.ChoiceField(choices=OrderStatus.choices, label="New Status") |
||||
|
||||
@admin.register(Order) |
||||
class OrderAdmin(admin.ModelAdmin): |
||||
list_display = ('id', 'get_email', 'date_ordered', 'status', 'total_price', 'get_shipping_address') |
||||
inlines = [OrderItemInline] |
||||
list_filter = ('status', 'payment_status') |
||||
readonly_fields = ('shipping_address_details',) |
||||
actions = ['change_order_status'] |
||||
autocomplete_fields = ['user'] # Add this line for user search functionality |
||||
search_fields = ['id', 'user__email', 'user__username', 'guest_user__email'] # Add this line |
||||
|
||||
def get_email(self, obj): |
||||
if obj.guest_user: |
||||
return obj.guest_user.email |
||||
else: |
||||
return obj.user.email |
||||
get_email.short_description = 'Email' |
||||
|
||||
def get_shipping_address(self, obj): |
||||
if obj.shipping_address: |
||||
return f"{obj.shipping_address.street_address}, {obj.shipping_address.city}" |
||||
return "No shipping address" |
||||
get_shipping_address.short_description = 'Shipping Address' |
||||
|
||||
def shipping_address_details(self, obj): |
||||
if obj.shipping_address: |
||||
return format_html( |
||||
""" |
||||
<div style="padding: 10px; background-color: #f9f9f9; border-radius: 4px;"> |
||||
<strong>Street:</strong> {}<br> |
||||
{} |
||||
<strong>City:</strong> {}<br> |
||||
<strong>State:</strong> {}<br> |
||||
<strong>Postal Code:</strong> {}<br> |
||||
<strong>Country:</strong> {} |
||||
</div> |
||||
""", |
||||
obj.shipping_address.street_address, |
||||
f"<strong>Apartment:</strong> {obj.shipping_address.apartment}<br>" if obj.shipping_address.apartment else "", |
||||
obj.shipping_address.city, |
||||
obj.shipping_address.state, |
||||
obj.shipping_address.postal_code, |
||||
obj.shipping_address.country, |
||||
) |
||||
return "No shipping address set" |
||||
shipping_address_details.short_description = 'Shipping Address Details' |
||||
|
||||
fieldsets = ( |
||||
(None, { |
||||
'fields': ('user', 'guest_user', 'status', 'payment_status', 'total_price') |
||||
}), |
||||
('Shipping Information', { |
||||
'fields': ('shipping_address_details',), |
||||
}), |
||||
('Payment Details', { |
||||
'fields': ('stripe_payment_intent_id', 'stripe_checkout_session_id', 'stripe_mode'), |
||||
'classes': ('collapse',) |
||||
}), |
||||
('Discount Information', { |
||||
'fields': ('coupon', 'discount_amount'), |
||||
'classes': ('collapse',) |
||||
}), |
||||
) |
||||
|
||||
def dashboard_view(self, request): |
||||
"""Dashboard view with order statistics""" |
||||
# Calculate order statistics |
||||
order_status_data = [] |
||||
total_orders = Order.objects.count() |
||||
total_revenue = Order.objects.aggregate(Sum('total_price'))['total_price__sum'] or 0 |
||||
|
||||
# Get data for each status |
||||
for status_choice in OrderStatus.choices: |
||||
status_code, status_label = status_choice |
||||
orders_for_status = Order.objects.filter(status=status_code) |
||||
count = orders_for_status.count() |
||||
total_amount = orders_for_status.aggregate(Sum('total_price'))['total_price__sum'] or 0 |
||||
avg_order_value = orders_for_status.aggregate(Avg('total_price'))['total_price__avg'] or 0 |
||||
percentage = (count / total_orders * 100) if total_orders > 0 else 0 |
||||
|
||||
order_status_data.append({ |
||||
'status': status_code, |
||||
'label': status_label, |
||||
'count': count, |
||||
'total_amount': total_amount, |
||||
'avg_order_value': avg_order_value, |
||||
'percentage': percentage |
||||
}) |
||||
|
||||
# Recent activity calculations |
||||
now = timezone.now() |
||||
today = now.date() |
||||
week_ago = today - timedelta(days=7) |
||||
month_ago = today - timedelta(days=30) |
||||
|
||||
orders_today = Order.objects.filter(date_ordered__date=today).count() |
||||
orders_this_week = Order.objects.filter(date_ordered__date__gte=week_ago).count() |
||||
orders_this_month = Order.objects.filter(date_ordered__date__gte=month_ago).count() |
||||
orders_to_prepare = Order.objects.filter(status=OrderStatus.PAID).count() |
||||
|
||||
context = { |
||||
'title': 'Shop Dashboard', |
||||
'app_label': 'shop', |
||||
'opts': Order._meta, |
||||
'order_status_data': order_status_data, |
||||
'total_orders': total_orders, |
||||
'total_revenue': total_revenue, |
||||
'orders_today': orders_today, |
||||
'orders_this_week': orders_this_week, |
||||
'orders_this_month': orders_this_month, |
||||
'orders_to_prepare': orders_to_prepare, |
||||
} |
||||
|
||||
return render(request, 'admin/shop/dashboard.html', context) |
||||
|
||||
def changelist_view(self, request, extra_context=None): |
||||
# If 'show_preparation' parameter is in the request, show the preparation view |
||||
if 'show_preparation' in request.GET: |
||||
return self.preparation_view(request) |
||||
|
||||
# Otherwise show the normal change list |
||||
extra_context = extra_context or {} |
||||
paid_orders_count = Order.objects.filter(status=OrderStatus.PAID).count() |
||||
extra_context['paid_orders_count'] = paid_orders_count |
||||
return super().changelist_view(request, extra_context=extra_context) |
||||
|
||||
def preparation_view(self, request): |
||||
"""View for items that need to be prepared""" |
||||
# Get paid orders |
||||
orders = Order.objects.filter(status=OrderStatus.PAID).order_by('-date_ordered') |
||||
|
||||
# Group items by product, color, size |
||||
items_by_variant = {} |
||||
all_items = OrderItem.objects.filter(order__status=OrderStatus.PAID) |
||||
|
||||
for item in all_items: |
||||
# Create a key for grouping items |
||||
key = ( |
||||
str(item.product.id), |
||||
str(item.color.id) if item.color else 'none', |
||||
str(item.size.id) if item.size else 'none' |
||||
) |
||||
|
||||
if key not in items_by_variant: |
||||
items_by_variant[key] = { |
||||
'product': item.product, |
||||
'color': item.color, |
||||
'size': item.size, |
||||
'quantity': 0, |
||||
'orders': set() |
||||
} |
||||
|
||||
items_by_variant[key]['quantity'] += item.quantity |
||||
items_by_variant[key]['orders'].add(item.order.id) |
||||
|
||||
# Convert to list and sort |
||||
items_list = list(items_by_variant.values()) |
||||
items_list.sort(key=lambda x: x['product'].title) |
||||
|
||||
context = { |
||||
'title': 'Orders to Prepare', |
||||
'app_label': 'shop', |
||||
'opts': Order._meta, |
||||
'orders': orders, |
||||
'items': items_list, |
||||
'total_orders': orders.count(), |
||||
'total_items': sum(i['quantity'] for i in items_list) |
||||
} |
||||
|
||||
return render( |
||||
request, |
||||
'admin/shop/order/preparation_view.html', |
||||
context |
||||
) |
||||
|
||||
def get_urls(self): |
||||
urls = super().get_urls() |
||||
custom_urls = [ |
||||
path('dashboard/', self.admin_site.admin_view(self.dashboard_view), name='shop_order_dashboard'), |
||||
path('prepare-all/', self.admin_site.admin_view(self.prepare_all_orders), name='prepare_all_orders'), |
||||
path('<int:order_id>/prepare/', self.admin_site.admin_view(self.prepare_order), name='prepare_order'), |
||||
path('<int:order_id>/cancel-refund/', self.admin_site.admin_view(self.cancel_and_refund_order), name='cancel_and_refund_order'), |
||||
] |
||||
return custom_urls + urls |
||||
|
||||
def prepare_all_orders(self, request): |
||||
if request.method == 'POST': |
||||
Order.objects.filter(status=OrderStatus.PAID).update(status=OrderStatus.PREPARED) |
||||
self.message_user(request, "All orders have been marked as prepared.") |
||||
return redirect('admin:shop_order_changelist') |
||||
|
||||
def prepare_order(self, request, order_id): |
||||
if request.method == 'POST': |
||||
order = Order.objects.get(id=order_id) |
||||
order.status = OrderStatus.PREPARED |
||||
order.save() |
||||
self.message_user(request, f"Order #{order_id} has been marked as prepared.") |
||||
return redirect('admin:shop_order_changelist') |
||||
|
||||
def cancel_and_refund_order(self, request, order_id): |
||||
if request.method == 'POST': |
||||
order = Order.objects.get(id=order_id) |
||||
try: |
||||
# Reuse the cancel_order logic from your views |
||||
from .views import cancel_order |
||||
cancel_order(request, order_id) |
||||
self.message_user(request, f"Order #{order_id} has been cancelled and refunded.") |
||||
except Exception as e: |
||||
self.message_user(request, f"Error cancelling order: {str(e)}", level='ERROR') |
||||
return redirect('admin:shop_order_changelist') |
||||
|
||||
def change_order_status(self, request, queryset): |
||||
"""Admin action to change the status of selected orders""" |
||||
form = None |
||||
|
||||
if 'apply' in request.POST: |
||||
form = ChangeOrderStatusForm(request.POST) |
||||
|
||||
if form.is_valid(): |
||||
status = form.cleaned_data['status'] |
||||
count = 0 |
||||
|
||||
for order in queryset: |
||||
order.status = status |
||||
order.save() |
||||
count += 1 |
||||
|
||||
self.message_user(request, f"{count} orders have been updated to status '{OrderStatus(status).label}'.") |
||||
return HttpResponseRedirect(request.get_full_path()) |
||||
|
||||
if not form: |
||||
form = ChangeOrderStatusForm(initial={'_selected_action': request.POST.getlist('_selected_action')}) |
||||
|
||||
context = { |
||||
'title': 'Change Order Status', |
||||
'orders': queryset, |
||||
'form': form, |
||||
'action': 'change_order_status' |
||||
} |
||||
return render(request, 'admin/shop/order/change_status.html', context) |
||||
|
||||
change_order_status.short_description = "Change status for selected orders" |
||||
|
||||
class GuestUserOrderInline(admin.TabularInline): |
||||
model = Order |
||||
extra = 0 |
||||
readonly_fields = ('date_ordered', 'total_price') |
||||
can_delete = False |
||||
show_change_link = True |
||||
exclude = ('user',) # Exclude the user field from the inline display |
||||
|
||||
@admin.register(GuestUser) |
||||
class GuestUserAdmin(admin.ModelAdmin): |
||||
list_display = ('email', 'phone') |
||||
inlines = [GuestUserOrderInline] |
||||
|
||||
@admin.register(Coupon) |
||||
class CouponAdmin(admin.ModelAdmin): |
||||
list_display = ('code', 'discount_amount', 'discount_percent', 'is_active', |
||||
'valid_from', 'valid_to', 'current_uses', 'max_uses') |
||||
list_filter = ('is_active', 'valid_from', 'valid_to') |
||||
search_fields = ('code', 'description') |
||||
readonly_fields = ('current_uses', 'created_at', 'stripe_coupon_id') |
||||
fieldsets = ( |
||||
('Basic Information', { |
||||
'fields': ('code', 'description', 'is_active') |
||||
}), |
||||
('Discount', { |
||||
'fields': ('discount_amount', 'discount_percent') |
||||
}), |
||||
('Validity', { |
||||
'fields': ('valid_from', 'valid_to', 'max_uses', 'current_uses') |
||||
}), |
||||
('Stripe Information', { |
||||
'fields': ('stripe_coupon_id',), |
||||
'classes': ('collapse',) |
||||
}), |
||||
) |
||||
|
||||
@admin.register(CouponUsage) |
||||
class CouponUsageAdmin(admin.ModelAdmin): |
||||
list_display = ('coupon', 'user', 'guest_email', 'order', 'used_at') |
||||
list_filter = ('used_at',) |
||||
search_fields = ('coupon__code', 'user__username', 'user__email', 'guest_email') |
||||
readonly_fields = ('used_at',) |
||||
@ -1,8 +0,0 @@ |
||||
from django.apps import AppConfig |
||||
|
||||
|
||||
class ShopConfig(AppConfig): |
||||
default_auto_field = 'django.db.models.BigAutoField' |
||||
name = 'shop' |
||||
def ready(self): |
||||
import shop.signals # Import signals to ensure they're connected |
||||
@ -1,94 +0,0 @@ |
||||
from .models import CartItem, Product, Color, Size |
||||
|
||||
def get_or_create_cart_id(request): |
||||
"""Get the cart ID from the session or create a new one""" |
||||
if 'cart_id' not in request.session: |
||||
request.session['cart_id'] = request.session.session_key or request.session.create() |
||||
return request.session['cart_id'] |
||||
|
||||
def get_cart_items(request): |
||||
"""Get all cart items for the current session""" |
||||
cart_id = get_or_create_cart_id(request) |
||||
return CartItem.objects.filter(session_id=cart_id) |
||||
|
||||
def add_to_cart(request, product_id, quantity=1, color_id=None, size_id=None): |
||||
"""Add a product to the cart or update its quantity""" |
||||
product = Product.objects.get(id=product_id) |
||||
cart_id = get_or_create_cart_id(request) |
||||
|
||||
color = Color.objects.get(id=color_id) if color_id else None |
||||
size = Size.objects.get(id=size_id) if size_id else None |
||||
|
||||
try: |
||||
# Try to get existing cart item with the same product, color, and size |
||||
cart_item = CartItem.objects.get( |
||||
product=product, |
||||
session_id=cart_id, |
||||
color=color, |
||||
size=size |
||||
) |
||||
cart_item.quantity += quantity |
||||
cart_item.save() |
||||
except CartItem.DoesNotExist: |
||||
# Create new cart item |
||||
cart_item = CartItem.objects.create( |
||||
product=product, |
||||
quantity=quantity, |
||||
session_id=cart_id, |
||||
color=color, |
||||
size=size |
||||
) |
||||
|
||||
return cart_item |
||||
|
||||
def remove_from_cart(request, product_id): |
||||
"""Remove a product from the cart""" |
||||
cart_id = get_or_create_cart_id(request) |
||||
CartItem.objects.filter(product_id=product_id, session_id=cart_id).delete() |
||||
|
||||
def update_cart_item(request, product_id, quantity): |
||||
"""Update the quantity of a cart item""" |
||||
cart_id = get_or_create_cart_id(request) |
||||
cart_item = CartItem.objects.get(product_id=product_id, session_id=cart_id) |
||||
|
||||
if quantity > 0: |
||||
cart_item.quantity = quantity |
||||
cart_item.save() |
||||
else: |
||||
cart_item.delete() |
||||
|
||||
def get_cart_total(request): |
||||
"""Calculate the total price of all items in the cart""" |
||||
return sum(item.product.price * item.quantity for item in get_cart_items(request)) |
||||
|
||||
def clear_cart(request): |
||||
"""Clear the cart""" |
||||
cart_id = get_or_create_cart_id(request) |
||||
CartItem.objects.filter(session_id=cart_id).delete() |
||||
|
||||
# Add this function to your cart.py file |
||||
def get_cart_item(request, item_id): |
||||
"""Get a specific cart item by its ID""" |
||||
cart_id = get_or_create_cart_id(request) |
||||
try: |
||||
return CartItem.objects.get(id=item_id, session_id=cart_id) |
||||
except CartItem.DoesNotExist: |
||||
raise Exception("Cart item not found") |
||||
|
||||
def transfer_cart(request, old_session_key): |
||||
""" |
||||
Transfer cart items from an anonymous session to an authenticated user's session |
||||
""" |
||||
from django.contrib.sessions.models import Session |
||||
from django.contrib.sessions.backends.db import SessionStore |
||||
|
||||
# Get the old session |
||||
try: |
||||
old_session = SessionStore(session_key=old_session_key) |
||||
# Check if there are cart items in the old session |
||||
if 'cart_items' in old_session: |
||||
# Transfer cart items to the new session |
||||
request.session['cart_items'] = old_session['cart_items'] |
||||
request.session.modified = True |
||||
except Session.DoesNotExist: |
||||
pass |
||||
@ -1,10 +0,0 @@ |
||||
from django.conf import settings |
||||
|
||||
def stripe_context(request): |
||||
"""Add Stripe-related context variables to templates""" |
||||
stripe_mode = getattr(settings, 'STRIPE_MODE', 'test') |
||||
return { |
||||
'STRIPE_PUBLISHABLE_KEY': settings.STRIPE_PUBLISHABLE_KEY, |
||||
'STRIPE_MODE': stripe_mode, |
||||
'STRIPE_IS_TEST_MODE': stripe_mode == 'test', |
||||
} |
||||
@ -1,22 +0,0 @@ |
||||
from django import forms |
||||
from .models import Coupon |
||||
from .models import ShippingAddress |
||||
|
||||
class GuestCheckoutForm(forms.Form): |
||||
email = forms.EmailField(required=True) |
||||
phone = forms.CharField(max_length=20, required=True, label="Téléphone portable") |
||||
|
||||
class CouponApplyForm(forms.Form): |
||||
code = forms.CharField(max_length=50) |
||||
|
||||
class ShippingAddressForm(forms.ModelForm): |
||||
class Meta: |
||||
model = ShippingAddress |
||||
fields = ['street_address', 'apartment', 'city', 'postal_code', 'country'] |
||||
widgets = { |
||||
'street_address': forms.TextInput(attrs={'placeholder': 'Adresse'}), |
||||
'apartment': forms.TextInput(attrs={'placeholder': 'Appartement (optionnel)'}), |
||||
'city': forms.TextInput(attrs={'placeholder': 'Ville'}), |
||||
'postal_code': forms.TextInput(attrs={'placeholder': 'Code postal'}), |
||||
'country': forms.TextInput(attrs={'placeholder': 'Pays'}), |
||||
} |
||||
@ -1,206 +0,0 @@ |
||||
from django.core.management.base import BaseCommand |
||||
from shop.models import Color, Size, Product |
||||
from django.conf import settings |
||||
|
||||
class Command(BaseCommand): |
||||
help = 'Creates initial data for the shop' |
||||
|
||||
def handle(self, *args, **kwargs): |
||||
# Create colors |
||||
self.stdout.write('Creating colors...') |
||||
colors = [ |
||||
{'name': 'Blanc', 'hex': '#FFFFFF', 'secondary_hex': None, 'ordering': 9}, |
||||
{'name': 'Blanc / Bleu Sport', 'hex': '#FFFFFF', 'secondary_hex': '#112B44', 'ordering': 10}, |
||||
{'name': 'Blanc / Gris Clair', 'hex': '#FFFFFF', 'secondary_hex': '#D3D3D3', 'ordering': 12}, |
||||
{'name': 'Bleu Sport', 'hex': '#112B44', 'secondary_hex': None, 'ordering': 20}, |
||||
{'name': 'Bleu Sport / Blanc', 'hex': '#112B44', 'secondary_hex': '#FFFFFF', 'ordering': 11}, |
||||
{'name': 'Bleu Sport / Bleu Sport Chiné', 'hex': '#112B44', 'secondary_hex': '#16395A', 'ordering': 22}, |
||||
{'name': 'Fuchsia', 'hex': '#C1366B', 'secondary_hex': None, 'ordering': 30}, |
||||
{'name': 'Corail / Noir', 'hex': '#FF7F50', 'secondary_hex': '#000000', 'ordering': 40}, |
||||
{'name': 'Gris Foncé Chiné / Noir', 'hex': '#4D4D4D', 'secondary_hex': '#000000', 'ordering': 50}, |
||||
{'name': 'Olive', 'hex': '#635E53', 'secondary_hex': None, 'ordering': 54}, |
||||
{'name': 'Kaki Foncé', 'hex': '#707163', 'secondary_hex': None, 'ordering': 55}, |
||||
{'name': 'Noir', 'hex': '#000000', 'secondary_hex': None, 'ordering': 60}, |
||||
{'name': 'Noir / Corail', 'hex': '#000000', 'secondary_hex': '#FF7F50', 'ordering': 61}, |
||||
{'name': 'Noir / Gris Foncé Chiné', 'hex': '#000000', 'secondary_hex': '#4D4D4D', 'ordering': 62}, |
||||
{'name': 'Rose Clair', 'hex': '#E7C8CF', 'secondary_hex': None, 'ordering': 31}, |
||||
{'name': 'Sand', 'hex': '#B4A885', 'secondary_hex': None, 'ordering': 32}, |
||||
] |
||||
|
||||
color_objects = {} |
||||
for color_data in colors: |
||||
color, created = Color.objects.get_or_create( |
||||
name=color_data['name'], |
||||
defaults={ |
||||
'colorHex': color_data['hex'], |
||||
'secondary_hex_color': color_data['secondary_hex'], |
||||
'ordering': color_data['ordering'] |
||||
} |
||||
) |
||||
color_objects[color_data['name']] = color |
||||
if created: |
||||
self.stdout.write(f'Created color: {color_data["name"]}') |
||||
else: |
||||
color.colorHex = color_data['hex'] |
||||
color.secondary_hex_color = color_data['secondary_hex'] |
||||
color.ordering = color_data['ordering'] |
||||
color.save() |
||||
self.stdout.write(f'Updated color: {color_data["name"]}') |
||||
|
||||
# Create sizes |
||||
self.stdout.write('Creating sizes...') |
||||
sizes = ['Taille Unique', 'XS', 'S', 'M', 'L', 'XL', 'XXL', '3XL', '4XL'] |
||||
|
||||
size_objects = {} |
||||
for name in sizes: |
||||
size, created = Size.objects.get_or_create(name=name) |
||||
size_objects[name] = size |
||||
if created: |
||||
self.stdout.write(f'Created size: {name}') |
||||
else: |
||||
self.stdout.write(f'Size already exists: {name}') |
||||
|
||||
# Create products |
||||
self.stdout.write('Creating products...') |
||||
products = [ |
||||
{ |
||||
'sku': 'PC001', |
||||
'title': 'Padel Club Cap', |
||||
'description': 'Casquette logo centre', |
||||
'price': 25.00, |
||||
'ordering_value': 1, |
||||
'cut': 0, # Unisex |
||||
'colors': ['Blanc', 'Bleu Sport', 'Noir'], |
||||
'sizes': ['Taille Unique'], |
||||
'image_filename': 'hat.jpg' |
||||
}, |
||||
{ |
||||
'sku': 'PC002', |
||||
'title': 'Padel Club Hoodie Femme', |
||||
'description': 'Hoodie femme logo cœur et dos', |
||||
'price': 50.00, |
||||
'ordering_value': 10, |
||||
'cut': 1, |
||||
'colors': ['Blanc', 'Bleu Sport', 'Kaki Foncé', 'Noir', 'Fuchsia'], |
||||
'sizes': ['XS', 'S', 'M', 'L', 'XL', 'XXL'], |
||||
'image_filename': 'PS_K473_WHITE.png.avif' |
||||
}, |
||||
{ |
||||
'sku': 'PC003', |
||||
'title': 'Padel Club Hoodie Homme', |
||||
'description': 'Hoodie homme logo cœur et dos', |
||||
'price': 50.00, |
||||
'ordering_value': 11, |
||||
'cut': 2, |
||||
'colors': ['Blanc', 'Bleu Sport', 'Kaki Foncé', 'Noir', 'Fuchsia'], |
||||
'sizes': ['XS', 'S', 'M', 'L', 'XL', 'XXL', '3XL', '4XL'], |
||||
'image_filename': 'PS_K476_WHITE.png.avif' |
||||
}, |
||||
{ |
||||
'sku': 'PC004', |
||||
'title': 'Débardeur Femme', |
||||
'description': 'Débardeur femme avec logo coeur.', |
||||
'price': 25.00, |
||||
'ordering_value': 20, |
||||
'cut': 1, # Women |
||||
'colors': ['Blanc / Bleu Sport', 'Noir / Corail', 'Noir / Gris Foncé Chiné'], |
||||
'sizes': ['XS', 'S', 'M', 'L', 'XL'], |
||||
'image_filename': 'PS_PA4031_WHITE-SPORTYNAVY.png.avif' |
||||
}, |
||||
{ |
||||
'sku': 'PC005', |
||||
'title': 'Jupe bicolore Femme', |
||||
'description': 'Avec short intégré logo jambe (sauf corail)', |
||||
'price': 30.00, |
||||
'ordering_value': 30, |
||||
'cut': 1, # Women |
||||
'colors': ['Blanc / Bleu Sport', 'Bleu Sport / Blanc', 'Corail / Noir', 'Noir / Gris Foncé Chiné'], |
||||
'sizes': ['XS', 'S', 'M', 'L', 'XL'], |
||||
'image_filename': 'PS_PA1031_WHITE-SPORTYNAVY.png.avif' |
||||
}, |
||||
{ |
||||
'sku': 'PC006', |
||||
'title': 'T-shirt Bicolore Homme', |
||||
'description': 'T-shirt bicolore avec logo coeur.', |
||||
'price': 25.00, |
||||
'ordering_value': 40, |
||||
'cut': 2, # Men |
||||
'colors': ['Blanc / Gris Clair', 'Bleu Sport / Blanc', 'Bleu Sport / Bleu Sport Chiné', 'Noir', 'Noir / Gris Foncé Chiné'], |
||||
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'], |
||||
'image_filename': 'tshirt_h.png' |
||||
}, |
||||
{ |
||||
'sku': 'PC007', |
||||
'title': 'Short Bicolore Homme', |
||||
'description': 'Short bicolore avec logo jambe.', |
||||
'price': 30.00, |
||||
'ordering_value': 50, |
||||
'cut': 2, # Men |
||||
'colors': ['Blanc / Bleu Sport', 'Blanc / Gris Clair', 'Noir', 'Gris Foncé Chiné / Noir'], |
||||
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'], |
||||
'image_filename': 'PS_PA1030_WHITE-SPORTYNAVY.png.avif' |
||||
}, |
||||
{ |
||||
'sku': 'PC008', |
||||
'title': 'T-shirt Simple Femme', |
||||
'description': 'T-shirt simple avec logo coeur.', |
||||
'price': 20.00, |
||||
'ordering_value': 60, |
||||
'cut': 1, # Women |
||||
'colors': ['Blanc', 'Bleu Sport', 'Sand', 'Noir', 'Kaki Foncé', 'Rose Clair'], |
||||
'sizes': ['XS','S', 'M', 'L', 'XL', 'XXL'], |
||||
'image_filename': 'PS_PA439_WHITE.png.avif' |
||||
}, |
||||
{ |
||||
'sku': 'PC009', |
||||
'title': 'T-shirt Simple Homme', |
||||
'description': 'T-shirt simple avec logo coeur.', |
||||
'price': 20.00, |
||||
'ordering_value': 61, |
||||
'cut': 2, # Men |
||||
'colors': ['Blanc', 'Bleu Sport', 'Sand', 'Noir', 'Olive', 'Rose Clair'], |
||||
'sizes': ['XS','S', 'M', 'L', 'XL', 'XXL', '3XL'], |
||||
'image_filename': 'PS_PA438_WHITE.png.avif' |
||||
}, |
||||
] |
||||
|
||||
for product_data in products: |
||||
product, created = Product.objects.update_or_create( |
||||
sku=product_data['sku'], |
||||
defaults={ |
||||
'title': product_data['title'], |
||||
'description': product_data.get('description', ''), |
||||
'price': product_data['price'], |
||||
'ordering_value': product_data['ordering_value'], |
||||
'cut': product_data['cut'] |
||||
} |
||||
) |
||||
|
||||
if created: |
||||
self.stdout.write(f'Created product: {product_data["sku"]} - {product_data["title"]}') |
||||
else: |
||||
self.stdout.write(f'Updated product: {product_data["sku"]} - {product_data["title"]}') |
||||
|
||||
# Handle the image path |
||||
if 'image_filename' in product_data and product_data['image_filename']: |
||||
image_path = f"{settings.STATIC_URL}shop/images/products/{product_data['image_filename']}" |
||||
if product.image != image_path: |
||||
product.image = image_path |
||||
product.save() |
||||
self.stdout.write(f'Updated image path to "{image_path}" for: {product_data["sku"]}') |
||||
|
||||
# Update colors - first clear existing then add new ones |
||||
product.colors.clear() |
||||
for color_name in product_data['colors']: |
||||
if color_name in color_objects: |
||||
product.colors.add(color_objects[color_name]) |
||||
self.stdout.write(f'Updated colors for: {product_data["sku"]}') |
||||
|
||||
# Update sizes - first clear existing then add new ones |
||||
product.sizes.clear() |
||||
for size_name in product_data['sizes']: |
||||
if size_name in size_objects: |
||||
product.sizes.add(size_objects[size_name]) |
||||
self.stdout.write(f'Updated sizes for: {product_data["sku"]}') |
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully created/updated shop data')) |
||||
@ -1,53 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-17 17:27 |
||||
|
||||
from django.conf import settings |
||||
from django.db import migrations, models |
||||
import django.db.models.deletion |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
initial = True |
||||
|
||||
dependencies = [ |
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.CreateModel( |
||||
name='Color', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('name', models.CharField(choices=[('Red', 'Red'), ('Blue', 'Blue'), ('Green', 'Green'), ('Black', 'Black'), ('White', 'White')], max_length=10, unique=True)), |
||||
], |
||||
), |
||||
migrations.CreateModel( |
||||
name='Size', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('name', models.CharField(choices=[('S', 'Small'), ('M', 'Medium'), ('L', 'Large'), ('XL', 'X-Large')], max_length=5, unique=True)), |
||||
], |
||||
), |
||||
migrations.CreateModel( |
||||
name='Product', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('title', models.CharField(max_length=200)), |
||||
('image', models.ImageField(blank=True, null=True, upload_to='products/')), |
||||
('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)), |
||||
('colors', models.ManyToManyField(blank=True, related_name='products', to='shop.color')), |
||||
('sizes', models.ManyToManyField(blank=True, related_name='products', to='shop.size')), |
||||
], |
||||
), |
||||
migrations.CreateModel( |
||||
name='CartItem', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('quantity', models.PositiveIntegerField(default=1)), |
||||
('session_id', models.CharField(blank=True, max_length=255, null=True)), |
||||
('date_added', models.DateTimeField(auto_now_add=True)), |
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.product')), |
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), |
||||
], |
||||
), |
||||
] |
||||
@ -1,39 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-17 17:31 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0001_initial'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.RenameModel( |
||||
old_name='Color', |
||||
new_name='ProductColor', |
||||
), |
||||
migrations.RenameModel( |
||||
old_name='Size', |
||||
new_name='ProductSize', |
||||
), |
||||
migrations.RemoveField( |
||||
model_name='product', |
||||
name='colors', |
||||
), |
||||
migrations.RemoveField( |
||||
model_name='product', |
||||
name='sizes', |
||||
), |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='product_colors', |
||||
field=models.ManyToManyField(blank=True, to='shop.productcolor'), |
||||
), |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='product_sizes', |
||||
field=models.ManyToManyField(blank=True, to='shop.productsize'), |
||||
), |
||||
] |
||||
@ -1,39 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-17 17:33 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0002_rename_color_productcolor_rename_size_productsize_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.RenameModel( |
||||
old_name='ProductColor', |
||||
new_name='Color', |
||||
), |
||||
migrations.RenameModel( |
||||
old_name='ProductSize', |
||||
new_name='Size', |
||||
), |
||||
migrations.RemoveField( |
||||
model_name='product', |
||||
name='product_colors', |
||||
), |
||||
migrations.RemoveField( |
||||
model_name='product', |
||||
name='product_sizes', |
||||
), |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='colors', |
||||
field=models.ManyToManyField(blank=True, related_name='products', to='shop.color'), |
||||
), |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='sizes', |
||||
field=models.ManyToManyField(blank=True, related_name='products', to='shop.size'), |
||||
), |
||||
] |
||||
@ -1,24 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 08:00 |
||||
|
||||
from django.db import migrations, models |
||||
import django.db.models.deletion |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0003_rename_productcolor_color_rename_productsize_size_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='cartitem', |
||||
name='color', |
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.color'), |
||||
), |
||||
migrations.AddField( |
||||
model_name='cartitem', |
||||
name='size', |
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.size'), |
||||
), |
||||
] |
||||
@ -1,23 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 08:59 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0004_cartitem_color_cartitem_size'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='color', |
||||
name='name', |
||||
field=models.CharField(choices=[('Red', 'Red'), ('Blue', 'Blue'), ('Green', 'Green'), ('Black', 'Black'), ('White', 'White')], max_length=20, unique=True), |
||||
), |
||||
migrations.AlterField( |
||||
model_name='size', |
||||
name='name', |
||||
field=models.CharField(choices=[('S', 'Small'), ('M', 'Medium'), ('L', 'Large'), ('XL', 'X-Large'), ('SINGLE', 'Unique')], max_length=20, unique=True), |
||||
), |
||||
] |
||||
@ -1,22 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 13:46 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0005_alter_color_name_alter_size_name'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterModelOptions( |
||||
name='product', |
||||
options={'ordering': ['order']}, |
||||
), |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='order', |
||||
field=models.IntegerField(default=0), |
||||
), |
||||
] |
||||
@ -1,18 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 13:49 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0006_alter_product_options_product_order'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='cut', |
||||
field=models.IntegerField(choices=[(1, 'Women'), (2, 'Men'), (3, 'Kids')], default=2), |
||||
), |
||||
] |
||||
@ -1,22 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 14:24 |
||||
|
||||
from django.db import migrations |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0007_product_cut'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterModelOptions( |
||||
name='product', |
||||
options={'ordering': ['ordering_value', 'cut']}, |
||||
), |
||||
migrations.RenameField( |
||||
model_name='product', |
||||
old_name='order', |
||||
new_name='ordering_value', |
||||
), |
||||
] |
||||
@ -1,38 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 14:36 |
||||
|
||||
from django.conf import settings |
||||
from django.db import migrations, models |
||||
import django.db.models.deletion |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
('shop', '0008_alter_product_options_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.CreateModel( |
||||
name='Order', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('date_ordered', models.DateTimeField(auto_now_add=True)), |
||||
('status', models.CharField(choices=[('PENDING', 'Pending'), ('PAID', 'Paid'), ('SHIPPED', 'Shipped'), ('DELIVERED', 'Delivered'), ('CANCELED', 'Canceled')], default='PENDING', max_length=20)), |
||||
('total_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)), |
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), |
||||
], |
||||
), |
||||
migrations.CreateModel( |
||||
name='OrderItem', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('quantity', models.PositiveIntegerField(default=1)), |
||||
('price', models.DecimalField(decimal_places=2, max_digits=10)), |
||||
('color', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.color')), |
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shop.order')), |
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.product')), |
||||
('size', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.size')), |
||||
], |
||||
), |
||||
] |
||||
@ -1,21 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 14:50 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0009_order_orderitem'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.CreateModel( |
||||
name='GuestUser', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('email', models.EmailField(max_length=254)), |
||||
('phone', models.CharField(max_length=20)), |
||||
], |
||||
), |
||||
] |
||||
@ -1,19 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 17:59 |
||||
|
||||
from django.db import migrations, models |
||||
import django.db.models.deletion |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0010_guestuser'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='order', |
||||
name='guest_user', |
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='shop.guestuser'), |
||||
), |
||||
] |
||||
@ -1,28 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-19 12:45 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0011_order_guest_user'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='order', |
||||
name='payment_status', |
||||
field=models.CharField(choices=[('UNPAID', 'Unpaid'), ('PAID', 'Paid'), ('FAILED', 'Failed')], default='UNPAID', max_length=20), |
||||
), |
||||
migrations.AddField( |
||||
model_name='order', |
||||
name='stripe_checkout_session_id', |
||||
field=models.CharField(blank=True, max_length=255, null=True), |
||||
), |
||||
migrations.AddField( |
||||
model_name='order', |
||||
name='stripe_payment_intent_id', |
||||
field=models.CharField(blank=True, max_length=255, null=True), |
||||
), |
||||
] |
||||
@ -1,23 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-19 19:00 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0012_order_payment_status_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='color', |
||||
name='colorHex', |
||||
field=models.CharField(default='#FFFFFF', help_text='Color in hex format (e.g. #FF0000)', max_length=7), |
||||
), |
||||
migrations.AlterField( |
||||
model_name='color', |
||||
name='name', |
||||
field=models.CharField(max_length=20, unique=True), |
||||
), |
||||
] |
||||
@ -1,18 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-20 09:57 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0013_color_colorhex_alter_color_name'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='size', |
||||
name='name', |
||||
field=models.CharField(max_length=20, unique=True), |
||||
), |
||||
] |
||||
@ -1,18 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-20 17:30 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0014_alter_size_name'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='product', |
||||
name='image', |
||||
field=models.CharField(blank=True, max_length=200, null=True), |
||||
), |
||||
] |
||||
@ -1,18 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-21 05:58 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0015_alter_product_image'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='order', |
||||
name='webhook_processed', |
||||
field=models.BooleanField(default=False), |
||||
), |
||||
] |
||||
@ -1,18 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-21 08:01 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0016_order_webhook_processed'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='order', |
||||
name='stripe_mode', |
||||
field=models.CharField(choices=[('test', 'Test Mode'), ('live', 'Live Mode')], default='test', max_length=10), |
||||
), |
||||
] |
||||
@ -1,18 +0,0 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-21 12:14 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0017_order_stripe_mode'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='color', |
||||
name='secondary_hex_color', |
||||
field=models.CharField(blank=True, help_text='Secondary color in hex format for split color display', max_length=7, null=True), |
||||
), |
||||
] |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue