diff --git a/accounts/admin.py b/accounts/admin.py index 77300ee..15b1c65 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -40,7 +40,7 @@ from .models import IAmPrincipalLocation class IAmPrincipalLocationAdmin(admin.ModelAdmin): - list_display = ("id", "principal", "latitude", "longitude") + list_display = ("id", "principal", "latitude", "longitude", "created_on", "modified_on") search_fields = ( "principal__first_name", "principal__last_name", diff --git a/accounts/api/serializers.py b/accounts/api/serializers.py index fcfff7d..5ee2ebe 100644 --- a/accounts/api/serializers.py +++ b/accounts/api/serializers.py @@ -6,10 +6,12 @@ from rest_framework import serializers from accounts.models import ( AppVersion, IAmPrincipal, + IAmPrincipalExtendedData, IAmPrincipalType, # IAmPrincipalKYCDetails, ) -from manage_events.models import EventPrincipalInteraction, PrincipalPreference + +from manage_events.models import EventInteractionType, EventPrincipalInteraction, FreeUsageFeatureLimit, PrincipalPreference from manage_referrals.models import ( ReferralCode, ReferralRecord, @@ -136,17 +138,15 @@ class PasswordResetSerializer(BasePasswordSerializer, serializers.ModelSerialize fields = ["password", "confirm_password"] +from phonenumbers import parse, phonenumberutil, NumberParseException + + class ProfileSerializer(serializers.ModelSerializer): profile_photo = serializers.ImageField(required=False) - email = serializers.CharField(read_only=True) - invite_count = serializers.SerializerMethodField(read_only=True) principal_type_name = serializers.SerializerMethodField(read_only=True) - has_active_subscription = serializers.SerializerMethodField(read_only=True) - has_preferences = serializers.SerializerMethodField(read_only=True) - register_complete = serializers.BooleanField(read_only=True) + email = serializers.CharField(read_only=True) is_active = serializers.BooleanField(read_only=True) - going_events_count = serializers.SerializerMethodField(read_only=True) - interested_events_count = serializers.SerializerMethodField(read_only=True) + phone_no = serializers.CharField(required=True) class Meta: model = IAmPrincipal @@ -157,21 +157,29 @@ class ProfileSerializer(serializers.ModelSerializer): "player_id", "first_name", "last_name", + 'business_name', + "phone_no", "email", - "invite_count", - "register_complete", - "has_active_subscription", - "has_preferences", "linkedin_profile", "youtube_profile", "facebook_profile", "instagram_profile", + "twitter_profile", "website", "is_active", - "going_events_count", - "interested_events_count", ] + # def validate_phone_no(self, value): + # try: + # # Parse the phone number + # phone_number = parse(value) + # # Check for validity + # if not phonenumberutil.is_valid_number(phone_number): + # raise serializers.ValidationError('Please enter a valid phone number.') + # return value + # except NumberParseException: + # raise serializers.ValidationError('The phone number format is invalid.') + def update(self, instance, validated_data): instance.profile_photo = validated_data.get( "profile_photo", instance.profile_photo @@ -180,14 +188,53 @@ class ProfileSerializer(serializers.ModelSerializer): instance.last_name = validated_data.get("last_name", instance.last_name) return super().update(instance, validated_data) + def get_image_url(self, obj, field_name, request): + image_field = getattr(obj, field_name) + if image_field: + return request.build_absolute_uri(image_field.url) + return "" + + def get_principal_type_name(self, obj): + return obj.principal_type.name if obj.principal_type else None + + def to_representation(self, instance): + data = super().to_representation(instance) + request = self.context.get("request") + data["profile_photo"] = self.get_image_url(instance, "profile_photo", request) + return data + +class ProfileExtendedDataSerializer(serializers.ModelSerializer): + invite_count = serializers.SerializerMethodField(read_only=True) + principal_type_name = serializers.SerializerMethodField(read_only=True) + has_active_subscription = serializers.SerializerMethodField(read_only=True) + preference = serializers.SerializerMethodField(read_only=True) + principal_preference_count = serializers.SerializerMethodField(read_only=True) + going_events_count = serializers.SerializerMethodField(read_only=True) + interested_events_count = serializers.SerializerMethodField(read_only=True) + feature_limit = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = IAmPrincipal + fields = [ + "principal_type_name", + "invite_count", + "register_complete", + "has_active_subscription", + "preference", + "principal_preference_count", + "going_events_count", + "interested_events_count", + "feature_limit" + ] + def get_going_events_count(self, obj): return EventPrincipalInteraction.objects.filter( - principal=obj, status="going" + principal=obj, status=EventInteractionType.GOING ).count() def get_interested_events_count(self, obj): return EventPrincipalInteraction.objects.filter( - principal=obj, status="interested" + principal=obj, status=EventInteractionType.INTERESTED ).count() def get_invite_count(self, obj): @@ -198,38 +245,34 @@ class ProfileSerializer(serializers.ModelSerializer): def get_principal_type_name(self, obj): return obj.principal_type.name if obj.principal_type else None - def get_has_preferences(self, obj): + def get_preference(self, obj): return PrincipalPreference.objects.filter(principal=obj).exists() - def get_image_url(self, obj, field_name, request): - image_field = getattr(obj, field_name) - if image_field: - return request.build_absolute_uri(image_field.url) - return "" + def get_principal_preference_count(self, obj): + principal_preference = PrincipalPreference.objects.filter(principal=obj).first() + if principal_preference: + categories = principal_preference.preferred_categories.all() + return categories.count() + return 0 def get_has_active_subscription(self, obj): subscription_status = { "has_active_subscription": False, "in_grace_period": False, "grace_period_end_date": None, + "subscription_id": None, } today = timezone.now().date() # Attempt to find the active subscription with the furthest grace_period_end_date - latest_subscription = ( - PrincipalSubscription.objects.filter( - principal=obj, - is_paid=True, - cancelled=False, - deleted=False, - active=True, - status=SubscriptionStatus.ACTIVE, - ) - .order_by("-grace_period_end_date") - .first() - ) # Order by descending grace_period_end_date and take the first + latest_subscription = PrincipalSubscription.get_principal_subscription(obj) + + print(f"subscrition record {latest_subscription}") if latest_subscription: + subscription_status["subscription_id"] = ( + latest_subscription.stripe_subscription_id + ) # Check if we're within the grace period if today <= latest_subscription.grace_period_end_date: subscription_status["has_active_subscription"] = ( @@ -245,13 +288,11 @@ class ProfileSerializer(serializers.ModelSerializer): ) return subscription_status - - def to_representation(self, instance): - data = super().to_representation(instance) - request = self.context.get("request") - data["profile_photo"] = self.get_image_url(instance, "profile_photo", request) - return data - + + def get_feature_limit(self, obj): + from manage_events.api.serializers import FreeUsageFeatureLimitSerializer + obj = FreeUsageFeatureLimit.objects.first() + return FreeUsageFeatureLimitSerializer().to_representation(obj) # class PrincipalKYCDetailsSerializer(serializers.ModelSerializer): # aadhar_front_image = serializers.ImageField(required=False) @@ -348,4 +389,10 @@ class AppVersionSerializer(serializers.ModelSerializer): "version", "force_upgrade", "recommend_upgrade", - ] \ No newline at end of file + ] + + +class IAmPrincipalExtendedDataSerializer(serializers.ModelSerializer): + class Meta: + model = IAmPrincipalExtendedData + fields = "__all__" diff --git a/accounts/api/urls.py b/accounts/api/urls.py index 1428632..783612e 100644 --- a/accounts/api/urls.py +++ b/accounts/api/urls.py @@ -17,7 +17,8 @@ urlpatterns = [ path('request-otp/', views.OtpRequestView.as_view(), name='send_otp'), path('verify-otp/', views.OTPVerificationView.as_view(), name='send_otp'), - # path('profile/view//', views.ProfileView.as_view(), name='profile_veiw'), + path('profile/extended-data/', views.ProfileExtendedView.as_view(), name='profile_veiw'), + path('profile/view/', views.ProfileView.as_view(), name='profile_veiw'), path('profile/edit/', views.ProfileView.as_view(), name='profile_edit'), path('profile/password-reset/', views.ProfilePasswordResetView.as_view(), name='password_reset'), @@ -38,5 +39,6 @@ urlpatterns = [ name="delete_account", ), path('version-check/', views.VersionCheck.as_view(), name='version_check'), + path('transfer-check/', views.AccountTransferCheckView.as_view(), name='transfer_check'), ] diff --git a/accounts/api/views.py b/accounts/api/views.py index 0c7b59e..f48fa32 100644 --- a/accounts/api/views.py +++ b/accounts/api/views.py @@ -17,6 +17,7 @@ from django.views.decorators.http import require_http_methods from accounts.models import ( AppVersion, IAmPrincipal, + IAmPrincipalExtendedData, IAmPrincipalOtp, IAmPrincipalSource, IAmPrincipalType, @@ -41,7 +42,9 @@ from .serializers import ( # RegistrationPasswordSerializer, # PhoneSerializer, EmailSerializer, + IAmPrincipalExtendedDataSerializer, PlayerIDSerializer, + ProfileExtendedDataSerializer, RegistrationPasswordSerializer, RegistrationSerializer, ReferralCodeSerializer, @@ -120,7 +123,7 @@ class RegistrationEmailView(APIView): email_service = EmailService( subject="Good Times - OTP", to=[email], - from_email=settings.EMAIL_HOST_USER, + from_email=settings.DEFAULT_FROM_EMAIL, ) email_service.load_template( "otp/otp.html", context={"OTP": otp, "action": "Register"} @@ -319,7 +322,7 @@ class OtpRequestView(APIView): email_service = EmailService( subject="Good Times - OTP", to=[email], - from_email=settings.EMAIL_HOST_USER, + from_email=settings.DEFAULT_FROM_EMAIL, ) email_service.load_template( "otp/otp.html", context={"OTP": otp, "action": "Login"} @@ -536,6 +539,21 @@ class ProfileView(APIView): ) return ApiResponse.success(message=constants.SUCCESS, data=serializer.data) +class ProfileExtendedView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + model = IAmPrincipal + serializer = ProfileExtendedDataSerializer + + def get(self, request, *args, **kwargs): + serializer = self.serializer(instance=request.user) + success_response = { + "status": status.HTTP_200_OK, + "message": constants.SUCCESS, + "data": serializer.data, + } + return ApiResponse.success(**success_response) + class ProfilePasswordResetView(APIView): authentication_classes = [JWTAuthentication] @@ -937,7 +955,7 @@ class SoftDeletePrincipalAPIView(APIView): def delete(self, request, format=None): principal = request.user - if principal.deleted: # Check if already deleted + if not principal.is_active: # Check if already deleted return ApiResponse.error( status=status.HTTP_400_BAD_REQUEST, message="Account already deleted.", @@ -945,7 +963,6 @@ class SoftDeletePrincipalAPIView(APIView): ) principal.is_active = False - principal.deleted = True principal.save() return ApiResponse.success( status=status.HTTP_200_OK, @@ -960,13 +977,75 @@ class VersionCheck(APIView): model = AppVersion def get(self, request, *args, **kwargs): - - device_type = request.GET.get('type') + + device_type = request.GET.get("type") if not device_type: - return ApiResponse.error(message=constants.FAILURE, errors="device type is required") + return ApiResponse.error( + message=constants.FAILURE, errors="device type is required" + ) # Query the database to retrieve the upgrade flags based on the app version version = self.model.objects.filter(app_type=device_type).last() version_data = AppVersionSerializer(version) - return ApiResponse.success(message=constants.SUCCESS, data=version_data.data) \ No newline at end of file + return ApiResponse.success(message=constants.SUCCESS, data=version_data.data) + + +class AccountTransferCheckView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + model = IAmPrincipalExtendedData + serializer_class = IAmPrincipalExtendedDataSerializer + + def get(self, request, *args, **kwargs): + print("request.user is ", request.user) + try: + obj = IAmPrincipalExtendedData.objects.get(principal=request.user) + except IAmPrincipalExtendedData.DoesNotExist: + # Create a dummy serializer record + obj = { + 'principal': request.user.id, + 'is_onboarded': False, + 'is_transferred': False, + 'transferred_on': None, + 'pwd_changed_post_transfer': False + } + return ApiResponse.success(message=constants.SUCCESS, data=obj) + + except Exception as e: + error_response = { + "status": status.HTTP_404_NOT_FOUND, + "message": constants.RECORD_NOT_FOUND, + "errors": str(e), + } + return ApiResponse.error(**error_response) + + serializer = self.serializer_class(obj) + print("serializer data", serializer) + return ApiResponse.success(message=constants.SUCCESS, data=serializer.data) + + def post(self, request, *args, **kwargs): + try: + obj = IAmPrincipalExtendedData.objects.get(principal=request.user) + except IAmPrincipalExtendedData.DoesNotExist: + # Create a dummy serializer record + obj = { + 'principal': request.user.id, + 'is_onboarded': False, + 'is_transferred': False, + 'transferred_on': None, + 'pwd_changed_post_transfer': False + } + return ApiResponse.success(message=constants.SUCCESS, data=obj) + except Exception as e: + error_response = { + "status": status.HTTP_404_NOT_FOUND, + "message": constants.RECORD_NOT_FOUND, + "errors": str(e), + } + return ApiResponse.error(**error_response) + + obj.pwd_changed_post_transfer = True + obj.save() + serializer = self.serializer_class(obj) + return ApiResponse.success(message=constants.SUCCESS, data=serializer.data) \ No newline at end of file diff --git a/accounts/context_processors.py b/accounts/context_processors.py index 98689de..0eecf0f 100644 --- a/accounts/context_processors.py +++ b/accounts/context_processors.py @@ -44,6 +44,7 @@ def compute_resource_action_constants(request): 'RESOURCE_MANAGE_NOTIFICATIONS': resource_action.RESOURCE_MANAGE_NOTIFICATIONS, 'RESOURCE_MANAGE_REFERRALS': resource_action.RESOURCE_MANAGE_REFERRALS, 'RESOURCE_MANAGE_FEEDBACK': resource_action.RESOURCE_MANAGE_FEEDBACK, + 'RESOURCE_MANAGE_COUPONS': resource_action.RESOURCE_MANAGE_COUPONS, 'RESOURCE_IAM_PRINCIPAL': resource_action.RESOURCE_IAM_PRINCIPAL, 'RESOURCE_IAM_PRINCIPAL_GROUP': resource_action.RESOURCE_IAM_PRINCIPAL_GROUP, 'RESOURCE_IAM_GROUP': resource_action.RESOURCE_IAM_GROUP, diff --git a/accounts/fixture_script.py b/accounts/fixture_script.py index 663ee5b..921f26d 100644 --- a/accounts/fixture_script.py +++ b/accounts/fixture_script.py @@ -27,7 +27,8 @@ from accounts.resource_action import ( RESOURCE_MANAGE_REFERRALS, RESOURCE_MANAGE_FEEDBACK, RESOURCE_MANAGE_WITHDRAWALS, - RESOURCE_MANAGE_BANK_ACCOUNTS + RESOURCE_MANAGE_BANK_ACCOUNTS, + RESOURCE_MANAGE_COUPONS ) # this variable store the data of model principaltype, action, resource fixture_data = [ @@ -334,4 +335,16 @@ fixture_data = [ "action": [1, 2, 3, 4], }, }, + { + "model": "accounts.iamappresource", + "pk": 18, + "fields": { + "name": RESOURCE_MANAGE_COUPONS, + "label": RESOURCE_MANAGE_COUPONS, + "slug": RESOURCE_MANAGE_COUPONS, + "created_on": "2023-09-28T16:17:42.815", + "modified_on": "2023-09-28T16:17:42.815", + "action": [1, 2, 3, 4], + }, + }, ] diff --git a/accounts/fixtures/resource_action_fixture.json b/accounts/fixtures/resource_action_fixture.json index df2e808..e48d27f 100644 --- a/accounts/fixtures/resource_action_fixture.json +++ b/accounts/fixtures/resource_action_fixture.json @@ -386,5 +386,22 @@ 4 ] } + }, + { + "model": "accounts.iamappresource", + "pk": 18, + "fields": { + "name": "manage_coupons", + "label": "manage_coupons", + "slug": "manage_coupons", + "created_on": "2023-09-28T16:17:42.815", + "modified_on": "2023-09-28T16:17:42.815", + "action": [ + 1, + 2, + 3, + 4 + ] + } } ] \ No newline at end of file diff --git a/accounts/forms.py b/accounts/forms.py index d4d90ee..9f07e76 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -6,6 +6,7 @@ from django.core import validators from django.utils.translation import gettext_lazy as _ from goodtimes import constants +from manage_events.models import EventCategory from . import models # from .backend import EmailBackend @@ -357,4 +358,92 @@ class IAmPrincipalResourceLinkForm(IAmPrincipalForm): principal.principal_resource.set(principal_resource_data) # Save the instance to the database if commit: - principal.save() \ No newline at end of file + principal.save() + + +class CreateCustomerForm(forms.Form): + profile_photo = forms.ImageField(label="Profile Image", widget=forms.ClearableFileInput(attrs={'class': 'filepond'}),) + first_name = forms.CharField(max_length=255, required=True, label='First Name') + last_name = forms.CharField(max_length=255, required=True, label='Last Name') + business_name = forms.CharField(max_length=200, required=True, label="Business Name") + email = forms.EmailField(required=True, label='Email') + phone_no = PhoneNumberField( + widget=forms.TextInput(), + label="Phone No" + ) + preferences = forms.ModelMultipleChoiceField( + queryset=EventCategory.objects.all(), + widget=forms.widgets.SelectMultiple( + attrs={"class": "form_select js-example-basic-multiple"} + ), + required=True, + label='Preferences' + ) + free_start_date = forms.DateField( + required=True, + label=_('Free period start date'), + help_text=_('Enter the start date of the free period') + ) + free_end_date = forms.DateField( + required=True, + label=_('Free period end date'), + help_text=_('Enter the end date of the free period') + ) + address_line1 = forms.CharField(widget=forms.Textarea(attrs={'rows': 4, 'cols': 40})) + city = forms.CharField(max_length=200, required=False, label="Region") + country = forms.CharField(max_length=200, required=False, label="Country") + website = forms.URLField(max_length=255, required=False, label="Website") + linkedin_profile = forms.URLField(max_length=200, required=False, label="LinkedIn") + facebook_profile = forms.URLField(max_length=200, required=False, label="Facebook") + instagram_profile = forms.URLField(max_length=200, required=False, label="Instagram") + twitter_profile = forms.URLField(max_length=200, required=False, label="Twitter") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['preferences'].queryset = EventCategory.objects.all() + +class UpdateCustomerForm(forms.Form): + profile_photo = forms.ImageField(label="Profile Image") + first_name = forms.CharField(max_length=255, required=True, label='First Name') + last_name = forms.CharField(max_length=255, required=True, label='Last Name') + business_name = forms.CharField(max_length=200, required=True, label="Business Name") + email = forms.EmailField(required=True, label='Email', widget=forms.TextInput(attrs={'readonly': 'readonly'})) + phone_no = PhoneNumberField( + widget=forms.TextInput(), + label="Phone No" + ) + preferences = forms.ModelMultipleChoiceField( + queryset=EventCategory.objects.all(), + widget=forms.widgets.SelectMultiple( + attrs={"class": "form_select js-example-basic-multiple"} + ), + required=True, + label='Preferences' + ) + free_start_date = forms.DateField( + required=True, + label=_('Free period start date'), + help_text=_('Enter the start date of the free period') + ) + free_end_date = forms.DateField( + required=True, + label=_('Free period end date'), + help_text=_('Enter the end date of the free period') + ) + address_line1 = forms.CharField(widget=forms.Textarea(attrs={'rows': 4, 'cols': 40})) + city = forms.CharField(max_length=200, required=False, label="Region") + country = forms.CharField(max_length=200, required=False, label="Country") + website = forms.URLField(max_length=255, required=False, label="Website") + linkedin_profile = forms.URLField(max_length=200, required=False, label="LinkedIn") + facebook_profile = forms.URLField(max_length=200, required=False, label="Facebook") + instagram_profile = forms.URLField(max_length=200, required=False, label="Instagram") + twitter_profile = forms.URLField(max_length=200, required=False, label="Twitter") + active = forms.BooleanField(required=False, label='Active', help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['preferences'].queryset = EventCategory.objects.all() + + +class UploadExcelForm(forms.Form): + file = forms.FileField() diff --git a/accounts/migrations/0010_alter_appversion_app_type.py b/accounts/migrations/0010_alter_appversion_app_type.py new file mode 100644 index 0000000..7997524 --- /dev/null +++ b/accounts/migrations/0010_alter_appversion_app_type.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.2 on 2024-05-31 11:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0009_appversion_app_type"), + ] + + operations = [ + migrations.AlterField( + model_name="appversion", + name="app_type", + field=models.CharField( + choices=[("android", "android"), ("ios", "ios")], max_length=10 + ), + ), + ] diff --git a/accounts/migrations/0011_alter_iamprincipallocation_principal.py b/accounts/migrations/0011_alter_iamprincipallocation_principal.py new file mode 100644 index 0000000..885aedf --- /dev/null +++ b/accounts/migrations/0011_alter_iamprincipallocation_principal.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.2 on 2024-06-20 08:17 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0010_alter_appversion_app_type"), + ] + + operations = [ + migrations.AlterField( + model_name="iamprincipallocation", + name="principal", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="principal_location", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/accounts/migrations/0012_iamprincipalextendeddata.py b/accounts/migrations/0012_iamprincipalextendeddata.py new file mode 100644 index 0000000..220b1e2 --- /dev/null +++ b/accounts/migrations/0012_iamprincipalextendeddata.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.2 on 2024-06-25 07:28 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0011_alter_iamprincipallocation_principal'), + ] + + operations = [ + migrations.CreateModel( + name='IAmPrincipalExtendedData', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_onboarded', models.BooleanField(default=False, help_text='Indicates whether the user was onboarded by an admin.')), + ('is_transferred', models.BooleanField(default=False, help_text='Indicates whether the account has been transferred to the user.')), + ('transferred_on', models.DateTimeField(blank=True, help_text='The date and time when the account was transferred to the user.', null=True)), + ('principal', models.OneToOneField(help_text='The principal user to which this extended data is related.', on_delete=django.db.models.deletion.CASCADE, related_name='extended_data', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'iam_principal_extended_data', + }, + ), + ] diff --git a/accounts/migrations/0013_iamprincipalextendeddata_pwd_changed_post_transfer.py b/accounts/migrations/0013_iamprincipalextendeddata_pwd_changed_post_transfer.py new file mode 100644 index 0000000..6815936 --- /dev/null +++ b/accounts/migrations/0013_iamprincipalextendeddata_pwd_changed_post_transfer.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-06-27 08:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0012_iamprincipalextendeddata'), + ] + + operations = [ + migrations.AddField( + model_name='iamprincipalextendeddata', + name='pwd_changed_post_transfer', + field=models.BooleanField(default=False, help_text='Indicates if the user changed their password after the account was transferred.'), + ), + ] diff --git a/accounts/migrations/0014_iamprincipal_business_name.py b/accounts/migrations/0014_iamprincipal_business_name.py new file mode 100644 index 0000000..36e5e8d --- /dev/null +++ b/accounts/migrations/0014_iamprincipal_business_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-08-11 16:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0013_iamprincipalextendeddata_pwd_changed_post_transfer'), + ] + + operations = [ + migrations.AddField( + model_name='iamprincipal', + name='business_name', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Business Name'), + ), + ] diff --git a/accounts/migrations/0015_iamprincipal_twitter_profile.py b/accounts/migrations/0015_iamprincipal_twitter_profile.py new file mode 100644 index 0000000..06cbd13 --- /dev/null +++ b/accounts/migrations/0015_iamprincipal_twitter_profile.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-08-12 08:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0014_iamprincipal_business_name'), + ] + + operations = [ + migrations.AddField( + model_name='iamprincipal', + name='twitter_profile', + field=models.URLField(blank=True, max_length=255, null=True, verbose_name='Principal Twitter'), + ), + ] diff --git a/accounts/migrations/0016_iamprincipalextendeddata_encrypted_pass.py b/accounts/migrations/0016_iamprincipalextendeddata_encrypted_pass.py new file mode 100644 index 0000000..9c4bda1 --- /dev/null +++ b/accounts/migrations/0016_iamprincipalextendeddata_encrypted_pass.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-12-20 12:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0015_iamprincipal_twitter_profile'), + ] + + operations = [ + migrations.AddField( + model_name='iamprincipalextendeddata', + name='encrypted_pass', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index c3f5508..538369b 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -13,6 +13,7 @@ from django.utils.text import slugify from phonenumber_field.modelfields import PhoneNumberField # from manage_subscriptions.models import Subscription + from goodtimes.utils import RandomGenerator from .resource_action import ( PRINCIPAL_TYPE_EVENT_USER, @@ -320,6 +321,8 @@ class IAmPrincipal(AbstractUser): blank=True, null=True, ) + business_name = models.CharField(verbose_name="Business Name", max_length=200, blank=True, null=True) + twitter_profile = models.URLField(verbose_name="Principal Twitter", max_length=255, null=True, blank=True) USERNAME_FIELD = "email" REQUIRED_FIELDS = [] @@ -331,8 +334,54 @@ class IAmPrincipal(AbstractUser): def __str__(self): return f"{self.email}" + + @staticmethod + def generate_random_password(): + """Generate a password in the format 'GoodTimes@xxxx'.""" + random_number = random.randint(1000, 9999) # Generate a 4-digit random number + return f"GoodTimes@{random_number}" +class IAmPrincipalExtendedData(models.Model): + + principal = models.OneToOneField( + IAmPrincipal, + related_name="extended_data", + on_delete=models.CASCADE, + help_text="The principal user to which this extended data is related." + ) + is_onboarded = models.BooleanField( + default=False, + help_text="Indicates whether the user was onboarded by an admin." + ) + is_transferred = models.BooleanField( + default=False, + help_text="Indicates whether the account has been transferred to the user." + ) + transferred_on = models.DateTimeField( + null=True, + blank=True, + help_text="The date and time when the account was transferred to the user." + ) + pwd_changed_post_transfer = models.BooleanField(default=False, help_text="Indicates if the user changed their password after the account was transferred.") + encrypted_pass = models.TextField(blank=True, null=True) + class Meta: + db_table = "iam_principal_extended_data" + + def __str__(self): + return f"Extended Data for {self.principal}" + + def save(self, *args, **kwargs): + if self.is_transferred and self.transferred_on is None: + self.transferred_on = datetime.datetime.now() + super().save(*args, **kwargs) + + @property + def decrypted_field(self): + from goodtimes.services import Encryptor + encryptor = Encryptor() + return encryptor.decrypt(self.encrypted_pass) + class IAmPrincipalResourceLink(models.Model): principal = models.ForeignKey( IAmPrincipal, @@ -419,7 +468,7 @@ class IAmPrincipalBiometric(BaseModel): class IAmPrincipalLocation(BaseModel): - principal = models.ForeignKey( + principal = models.OneToOneField( IAmPrincipal, related_name="principal_location", on_delete=models.CASCADE ) latitude = models.DecimalField(max_digits=18, decimal_places=15) @@ -432,76 +481,6 @@ class IAmPrincipalLocation(BaseModel): return f"{self.principal.first_name}:{self.latitude}, {self.longitude}" -# Excluded in migrations -# class IAmPrincipalKYCDetails(models.Model): -# # the below is the table structure from Hritik Dhanawde for KYC -# kid = -# status = -# customer_identifier = -# reference_id = -# customer_name = -# reference_id = -# customer_name = -# workflow_name = -# template_id = -# kyc_created_at = -# access_token = - -# # Regex pattern for Aadhar number with exactly 12 digits -# AADHAR_REGEX = r"^\d{12}$" -# # Regex pattern for PAN number in the format AAAAB1234C -# PAN_REGEX = r"^[A-Z]{5}[0-9]{4}[A-Z]$" -# # Regex pattern for IFSC code (11 alphanumeric characters) -# IFSC_REGEX = r"^[A-Za-z]{4}\d{7}$" - -# principal = models.OneToOneField( -# IAmPrincipal, on_delete=models.CASCADE -# ) # Assuming IAmPrincipal is the user model. -# aadhar_front_image = models.ImageField(upload_to="kyc/", blank=True, null=True) -# aadhar_back_image = models.ImageField(upload_to="kyc/", blank=True, null=True) -# aadhar_number = models.CharField( -# max_length=12, -# blank=True, -# null=True, -# validators=[ -# RegexValidator(AADHAR_REGEX, message="Aadhar number must be 12 digits.") -# ], -# ) -# pan_image = models.ImageField(upload_to="kyc/", blank=True, null=True) -# pan_number = models.CharField( -# max_length=10, -# blank=True, -# null=True, -# validators=[ -# RegexValidator( -# PAN_REGEX, message="PAN number must be in the format AAAAB1234C." -# ) -# ], -# ) -# is_aadhar_verified = models.BooleanField(default=False) -# is_pan_verified = models.BooleanField(default=False) -# account_no = models.CharField(max_length=20, blank=True, null=True) -# bank_name = models.CharField(max_length=100, blank=True, null=True) -# branch_name = models.CharField(max_length=100, blank=True, null=True) -# ifsc_code = models.CharField( -# max_length=11, -# blank=True, -# null=True, -# validators=[ -# RegexValidator( -# IFSC_REGEX, message="IFSC code must be 11 alphanumeric characters." -# ) -# ], -# ) - -# class Meta: -# db_table = "iam_principal_kyc_details" - - -# def __str__(self): -# return f"KYC Information for {self.principal.email}" - - class AppType(models.TextChoices): ANDROID = "android", "android" IOS = "ios", "ios" diff --git a/accounts/resource_action.py b/accounts/resource_action.py index 3e10751..7bad7a7 100644 --- a/accounts/resource_action.py +++ b/accounts/resource_action.py @@ -28,6 +28,7 @@ RESOURCE_MANAGE_REFERRALS = "manage_referrals" RESOURCE_MANAGE_NOTIFICATIONS = "manage_notifications" RESOURCE_MANAGE_WITHDRAWALS = "manage_withdrawals" RESOURCE_MANAGE_BANK_ACCOUNTS = "manage_bank_accounts" +RESOURCE_MANAGE_COUPONS = "manage_coupons" # These constants are used solely for managing the active and inactive state of pages diff --git a/accounts/urls.py b/accounts/urls.py index d9dc83a..369f3be 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -24,7 +24,7 @@ urlpatterns = [ path('principal/add/', views.PrincipalCreateOrUpdateView.as_view(), name="principal_add"), path('principal/edit/', views.PrincipalCreateOrUpdateView.as_view(), name="principal_edit"), # path('principal/delete/', views.PrincipalDeleteView.as_view(), name="principal_delete"), - path('principal/resource/permission/edit//', views.PrincipalResourcePermissionEditView.as_view(), + path('principal/resource/permission/edit//', views.PrincipalResourcePermissionEditView.as_view(), name="principal_resource_permission_edit"), path('principal/group/link/', views.PrincipalGroupLinkListView.as_view(), name="principal_group_link_list"), @@ -42,7 +42,15 @@ urlpatterns = [ path('principal/role/delete//', views.AppRoleDeleteView.as_view(), name="role_delete"), path('customer/', views.CustomerListView.as_view(), name="customer_list"), - + path('customer/get-decrypted-password//', views.GetDecryptedPasswordView.as_view(), name='get_decrypted_password'), + path('customer/add/', views.CustomerCreateView.as_view(), name="customer_add"), + path('customer/edit//', views.CustomerUpdateView.as_view(), name="customer_edit"), + path('customer/detail//', views.CustomerDetailView.as_view(), name="customer_detail"), + path('customer/transfer//', views.CustomerTransferView.as_view(), name="customer_transfer"), + path('customer/check-email/', views.CustomerCheckEmail.as_view(), name="customer_check_email"), + path('customer/download-excel-template/', views.export_excel_template, name='download_excel_template'), + path('customer/import-customer-data/', views.CustomerImportView.as_view(), name='import_customer_data'), + path('customer/export-customer-data/', views.CustomerExportView.as_view(), name='export_customer_data'), # ignore this to path this for example setup path('principal/example/', TemplateView.as_view(template_name="accounts/iam_module/example_form.html"), name="example_add"), diff --git a/accounts/views.py b/accounts/views.py index 6f5c89d..c4052f2 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,10 +1,12 @@ import logging +from django.conf import settings from django.db.models import Count, Q from django.contrib import messages from django.contrib.auth import authenticate, login, logout from django.contrib.auth.views import LogoutView from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.hashers import make_password from django.contrib.auth.views import ( LoginView, PasswordResetCompleteView, @@ -13,16 +15,26 @@ from django.contrib.auth.views import ( PasswordResetView, ) from django.core.exceptions import ValidationError +from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse_lazy from django.views import generic from django.db import models, transaction, IntegrityError from django.utils import timezone +import phonenumbers from accounts import permission from goodtimes import constants +from goodtimes.services import EmailService, Encryptor +from goodtimes.utils import JsonResponseUtil +from manage_events.models import EventCategory, PrincipalPreference +from manage_referrals.models import ReferralCode +from manage_subscriptions.models import PrincipalSubscription, Subscription +import datetime +from datetime import datetime, timedelta from . import resource_action from .forms import ( + CreateCustomerForm, CustomAuthenticationForm, IAmPrincipalForm, IAmPrincipalGroupRoleLinkForm, @@ -30,9 +42,12 @@ from .forms import ( IAmPrincipalRoleAppResourceActionLinkForm, IAmPrincipalGroupLinkForm, ProfileEditForm, + UpdateCustomerForm, + UploadExcelForm, ) from .models import ( IAmPrincipal, + IAmPrincipalExtendedData, IAmPrincipalType, IAmAppResourceActionLink, IAmPrincipalGroup, @@ -171,10 +186,6 @@ class PrincipalListView(LoginRequiredMixin, generic.ListView): context["page_name"] = self.page_name return context - -import datetime - - class PrincipalCreateOrUpdateView(LoginRequiredMixin, generic.View): page_name = resource_action.RESOURCE_IAM_PRINCIPAL model = IAmPrincipal @@ -557,6 +568,266 @@ class AppRoleDeleteView(LoginRequiredMixin, generic.View): """Customer""" +class CustomerCheckEmail(generic.View): + model = IAmPrincipal + + def post(self, request, *args, **kwargs): + email = request.POST.get('email') + print("check email is cllaed ", email) + if self.model.objects.filter(email=email).exists(): + print("exist called") + return JsonResponse({'message': 'This email address is already in use.'}, status=400) + else: + print("email is valid") + return JsonResponse({'message': 'Email is available.'}, status=200) + + +class CustomerCreateView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_CUSTOMER + resource = resource_action.RESOURCE_MANAGE_CUSTOMER + model = IAmPrincipal + form_class = CreateCustomerForm + template_name = "accounts/customer/customer_add.html" + success_url = reverse_lazy("accounts:customer_list") + success_message = "Saved Successfully" + error_message = "An error occurred while saving the data." + + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + form = self.form_class() + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + print(request.POST) + # return redirect(self.success_url) + form = self.form_class(request.POST, request.FILES) + context = self.get_context_data(form=form) + if not form.is_valid(): + return render(request, self.template_name, context=context) + + free_subscription = Subscription.objects.filter(is_free=True, active=True).first() + + if not free_subscription: + messages.error(self.request, "Create a free subscription record for admin in manage subscription") + return render(request, self.template_name, context=context) + + try: + with transaction.atomic(): + random_password = IAmPrincipal.generate_random_password() + + # Encrypt the password + encryptor = Encryptor() + encrypted_password = encryptor.encrypt(random_password) + + # save principal data + principal_obj = IAmPrincipal.objects.create( + profile_photo = form.cleaned_data.get("profile_photo"), + email=form.cleaned_data.get('email'), + first_name=form.cleaned_data.get('first_name'), + last_name=form.cleaned_data.get('last_name'), + business_name=form.cleaned_data.get('business_name'), + phone_no=form.cleaned_data.get('phone_no'), + password=make_password(random_password), + username=form.cleaned_data.get("email"), + email_verified=True, + register_complete=True, + principal_type=IAmPrincipalType.objects.get(name=resource_action.PRINCIPAL_TYPE_EVENT_MANAGER), + address_line1=form.cleaned_data.get("address_line1"), + city=form.cleaned_data.get("city"), + country=form.cleaned_data.get("country"), + website=form.cleaned_data.get("website"), + linkedin_profile=form.cleaned_data.get("linkedin_profile"), + facebook_profile=form.cleaned_data.get("facebook_profile"), + instagram_profile=form.cleaned_data.get("instagram_profile"), + twitter_profile=form.cleaned_data.get("twitter_profile"), + ) + + # generate referralcode of manager + ReferralCode.create_referral_code_for_user_manager( + principal=principal_obj, principal_type=principal_obj.principal_type + ) + + IAmPrincipalExtendedData.objects.create( + principal=principal_obj, + is_onboarded=True, + encrypted_pass=encrypted_password, # Save encrypted password + ) + + # save principal preferences record + principal_preference = PrincipalPreference.objects.create(principal=principal_obj) + principal_preference.preferred_categories.set(form.cleaned_data.get("preferences")) + + principal_subscription = PrincipalSubscription.objects.create( + start_date=form.cleaned_data.get("free_start_date"), + end_date=form.cleaned_data.get("free_end_date"), + principal=principal_obj, + grace_period_end_date=PrincipalSubscription.generate_grace_period_end_date(form.cleaned_data.get("free_end_date")), + is_paid=True, + subscription=free_subscription + ) + messages.success(self.request, constants.REGISTRATION_SUCCESS) + return redirect(self.success_url) + except Exception as e: + print("errror is ", e) + messages.error(self.request, str(e)) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + +class CustomerUpdateView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_CUSTOMER + resource = resource_action.RESOURCE_MANAGE_CUSTOMER + model = IAmPrincipal + form_class = UpdateCustomerForm + template_name = "accounts/customer/customer_edit.html" + success_url = reverse_lazy("accounts:customer_list") + success_message = "Updated Successfully" + error_message = "An error occurred while saving the data." + + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + principal_id = kwargs.get("pk") + try: + principal_obj = IAmPrincipal.objects.get(pk=principal_id) + except Exception as e: + messages.error(request, f"No Record of id {principal_id} is found") + return redirect(self.success_url) + + print(f"principal address is {principal_obj.address_line1}") + + initial_data = { + "profile_photo": principal_obj.profile_photo, + "first_name": principal_obj.first_name, + "last_name": principal_obj.last_name, + "email": principal_obj.email, + "business_name": principal_obj.business_name, + "phone_no": principal_obj.phone_no, + "address_line1": principal_obj.address_line1, + "city": principal_obj.city, + "country": principal_obj.country, + "website": principal_obj.website, + "linkedin_profile": principal_obj.linkedin_profile, + "facebook_profile": principal_obj.facebook_profile, + "instagram_profile": principal_obj.instagram_profile, + "twitter_profile": principal_obj.twitter_profile, + "active": principal_obj.is_active + } + + try: + principal_preference = PrincipalPreference.objects.get(principal=principal_obj) + initial_data["preferences"] = list(principal_preference.preferred_categories.all().values_list("id", flat=True)) + except PrincipalPreference.DoesNotExist: + initial_data["preferences"] = [] + + try: + subscription = PrincipalSubscription.objects.filter(principal=principal_obj).latest("created_on") + initial_data["free_start_date"] = subscription.start_date + initial_data["free_end_date"] = subscription.end_date + except PrincipalSubscription.DoesNotExist: + initial_data["free_start_date"] = None + initial_data["free_end_date"] = None + + form = self.form_class(initial=initial_data) + context = self.get_context_data(form=form, principal_obj=principal_obj) + print("context dta is ", context) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + principal_id = kwargs.get("pk") + try: + principal_obj = IAmPrincipal.objects.get(pk=principal_id) + except Exception as e: + messages.error(request, f"No Record of customer id {principal_id} is found") + return redirect(self.success_url) + form = self.form_class(request.POST, request.FILES) + print(request.POST) + if not form.is_valid(): + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + try: + with transaction.atomic(): + # update principal data + principal_obj.profile_photo = form.cleaned_data.get('profile_photo') + principal_obj.first_name = form.cleaned_data.get('first_name') + principal_obj.last_name = form.cleaned_data.get('last_name') + principal_obj.business_name = form.cleaned_data.get("business_name") + principal_obj.phone_no = form.cleaned_data.get("phone_no") + principal_obj.address_line1 = form.cleaned_data.get("address_line1") + principal_obj.city = form.cleaned_data.get("city") + principal_obj.country = form.cleaned_data.get("country") + principal_obj.website = form.cleaned_data.get("website") + principal_obj.linkedin_profile = form.cleaned_data.get("linkedin_profile") + principal_obj.facebook_profile = form.cleaned_data.get("facebook_profile") + principal_obj.instagram_profile = form.cleaned_data.get("instagram_profile") + principal_obj.twitter_profile = form.cleaned_data.get("twitter_profile") + principal_obj.is_active = form.cleaned_data.get("active") + principal_obj.save() + + # update principal preferences record + principal_preference, _ = PrincipalPreference.objects.get_or_create(principal=principal_obj) + principal_preference.preferred_categories.set(form.cleaned_data.get("preferences")) + + # update principal subscription record + principal_subscription = PrincipalSubscription.objects.filter(principal=principal_obj).order_by('-end_date').first() + if principal_subscription: + principal_subscription.start_date = form.cleaned_data.get("free_start_date") + principal_subscription.end_date = form.cleaned_data.get("free_end_date") + principal_subscription.grace_period_end_date = form.cleaned_data.get("free_end_date") + timedelta(days=15) + principal_subscription.save() + else: + PrincipalSubscription.objects.create( + principal=principal_obj, + start_date=form.cleaned_data.get("free_start_date"), + end_date=form.cleaned_data.get("free_end_date"), + grace_period_end_date=PrincipalSubscription.generate_grace_period_end_date(form.cleaned_data.get("free_end_date")), + is_paid=True, + subscription=Subscription.objects.filter().first() # Assuming you want to link a default subscription + ) + + messages.success(self.request, self.success_message) + return redirect(self.success_url) + except Exception as e: + messages.error(self.request, str(e)) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + +class CustomerDetailView(LoginRequiredMixin, generic.DetailView): + page_name = resource_action.RESOURCE_MANAGE_CUSTOMER + resource = resource_action.RESOURCE_MANAGE_CUSTOMER + action = resource_action.ACTION_READ + template_name = 'accounts/customer/customer_detail.html' + + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + principal_obj = IAmPrincipal.objects.get(pk=kwargs.get("pk")) + try: + principal_preference = PrincipalPreference.objects.get(principal_id=principal_obj.id) + except Exception as e: + principal_preference = None + principal_subscription = PrincipalSubscription.objects.filter(principal=principal_obj).order_by("-start_date").first() + context = self.get_context_data(principal_obj=principal_obj,principal_preference=principal_preference,principal_subscription=principal_subscription) + return render(request, self.template_name, context=context) class CustomerListView(LoginRequiredMixin, generic.ListView): page_name = resource_action.RESOURCE_MANAGE_CUSTOMER @@ -570,7 +841,7 @@ class CustomerListView(LoginRequiredMixin, generic.ListView): queryset = ( super() .get_queryset() - .select_related("principal_type", "principal_source") + .select_related("principal_type", "principal_source", "extended_data") .filter( models.Q( principal_type__name=resource_action.PRINCIPAL_TYPE_EVENT_MANAGER @@ -601,12 +872,459 @@ class CustomerListView(LoginRequiredMixin, generic.ListView): context = super().get_context_data(**kwargs) context["page_name"] = self.page_name return context + + +class GetDecryptedPasswordView(generic.View): + def get(self, request, customer_id): + try: + # Fetch the extended data for the customer + extended_data = IAmPrincipalExtendedData.objects.get(principal_id=customer_id) + + if not extended_data.encrypted_pass: + return JsonResponse({"success": False, "message": "No password found."}) + + # Use Encryptor to decrypt the password + encryptor = Encryptor() + decrypted_password = encryptor.decrypt(extended_data.encrypted_pass) + + return JsonResponse({"success": True, "decrypted_password": decrypted_password}) + except IAmPrincipalExtendedData.DoesNotExist: + return JsonResponse({"success": False, "message": "Customer not found."}) + except Exception as e: + return JsonResponse({"success": False, "message": str(e)}) + + +import pandas as pd +from openpyxl import Workbook, load_workbook +from openpyxl.worksheet.datavalidation import DataValidation +from openpyxl.styles import Font +from django.http import HttpResponse + +# def export_excel_template(request): +# # Define the columns and create an empty DataFrame +# columns = ['First Name', 'Last Name', 'Email', 'Preferences', 'Free period start date', 'Free period end date'] +# df = pd.DataFrame(columns=columns) + +# # Create a workbook and select the active worksheet +# wb = Workbook() +# ws = wb.active +# ws.title = 'Customer Registration' + +# # # Write the column headers +# # for col_num, column_title in enumerate(df.columns, 1): +# # cell = ws.cell(row=1, column=col_num, value=column_title) +# # cell.font = Font(bold=True) + +# # # Create a hidden sheet for preferences +# # ws_prefs = wb.create_sheet(title="Preferences") +# # ws_prefs.sheet_state = 'hidden' + +# # # Fetch preferences options from the EventCategory model +# # preferences_options = EventCategory.objects.values_list('title', flat=True) + +# # # Write preferences to the hidden sheet +# # for row_num, preference in enumerate(preferences_options, 1): +# # ws_prefs.cell(row=row_num, column=1, value=preference) + +# # # Define the range for preferences in the hidden sheet +# # preferences_range = f"Preferences!$A$1:$A${len(preferences_options)}" + +# # # Add Data Validation for preferences (drop-down list) +# # dv_preferences = DataValidation( +# # type="list", +# # formula1=preferences_range, +# # allow_blank=True, +# # showDropDown=True +# # ) +# # ws.add_data_validation(dv_preferences) +# # dv_preferences.add(f'D2:D1048576') # Apply to the whole column + +# # # Add Data Validation for date comparison +# # dv_start_date = DataValidation( +# # type="date", +# # operator="greaterThan", +# # formula1='"1900-01-01"', +# # showErrorMessage=True +# # ) +# # dv_end_date = DataValidation( +# # type="custom", +# # formula1="=AND(ISNUMBER(F2), F2>E2)", +# # showErrorMessage=True, +# # errorTitle="Invalid Date", +# # error="End date must be greater than start date." +# # ) +# # ws.add_data_validation(dv_start_date) +# # ws.add_data_validation(dv_end_date) +# # dv_start_date.add(f'E2:E1048576') +# # dv_end_date.add(f'F2:F1048576') + +# # Save the workbook to a bytes buffer +# response = HttpResponse(content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') +# response['Content-Disposition'] = 'attachment; filename=customer_registration_template.xlsx' +# wb.save(response) +# return response + +# from openpyxl.styles import Font +def export_excel_template(request): + # Define the columns for the Customer Registration sheet + columns = [ + 'First Name', + 'Last Name', + 'Business Name', + 'Email', + 'Phone No (+919999999999)', + 'Preferences (should be separated by comma)', + 'Free period start date (DD-MM-YYYY)', + 'Free period end date (DD-MM-YYYY)', + 'Address', + 'Region', + 'Country', + 'Website', + 'LinkedIn', + 'Facebook', + 'Instagram', + 'Twitter', + ] + df = pd.DataFrame(columns=columns) + + # Create a workbook and add the Customer Registration worksheet + wb = Workbook() + ws_customer = wb.active + ws_customer.title = 'Manager Onboarding' + + # Write the column headers for the Customer Registration sheet + for col_num, column_title in enumerate(df.columns, 1): + cell = ws_customer.cell(row=1, column=col_num, value=column_title) + cell.font = Font(bold=True) + + # Create the Readme worksheet + ws_readme = wb.create_sheet(title='Readme') + + # Add information about each field to the Readme sheet + readme_data = [ + ['Field Name', 'Description'], + ['First Name', 'The first name of the customer. This is a required field.'], + ['Last Name', 'The last name of the customer. This is a required field.'], + ['Business Name', 'The official name of the customer\'s business or organization.'], + ['Email', 'The email address of the customer. This must be a unique email not already used in the system. This is a required Field'], + ['Phone No', 'The business phone number. It should include the country code if applicable (+919999999999).'], + ['Category', 'A comma-separated list of event categories the customer is interested in. These should match existing categories in the system. This is a required Field'], + ['Free period start date', 'The start date of the customer\'s free trial period, formatted as DD-MM-YYYY.'], + ['Free period end date', 'The end date of the customer\'s free trial period, formatted as DD-MM-YYYY. This date must be later than the start date.'], + ['Address', 'The complete business address, including street, city, and postal code.'], + ['Region', 'The geographic region where the business operates.'], + ['Country', 'The country where the business is based.'], + ['Website', 'The URL of the business\'s official website. Ensure it includes "http://" or "https://".'], + ['LinkedIn', 'The LinkedIn profile URL of the business. Ensure it includes "http://" or "https://".'], + ['Facebook', 'The Facebook page URL of the business. Ensure it includes "http://" or "https://".'], + ['Instagram', 'The Instagram profile URL of the business. Ensure it includes "http://" or "https://".'], + ['Twitter', 'The Twitter handle or profile URL of the business. Ensure it includes "http://" or "https://".'], + ] + + # Write the Readme data to the Readme worksheet + for row_num, row_data in enumerate(readme_data, 1): + for col_num, cell_value in enumerate(row_data, 1): + cell = ws_readme.cell(row=row_num, column=col_num, value=cell_value) + if row_num == 1: # Make the header bold + cell.font = Font(bold=True) + + # Save the workbook to a bytes buffer + response = HttpResponse(content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + response['Content-Disposition'] = 'attachment; filename=customer_registration_template.xlsx' + wb.save(response) + + return response + +class CustomerTransferView(LoginRequiredMixin, generic.View): + model = IAmPrincipal + + def get(self, request, *args, **kwargs): + try: + principal_obj = self.model.objects.get(pk=kwargs.get("pk")) + except self.model.DoesNotExist: + messages.error(request, "Something went wrong") + return redirect(reverse_lazy("accounts:customer_detail")) + + email_service = EmailService( + subject="Your Exclusive Account Access Details with Good Times!", + to=principal_obj.email, + from_email=settings.DEFAULT_FROM_EMAIL, + ) + + # Send the email + try: + principal_preference = IAmPrincipalExtendedData.objects.get(principal=principal_obj) + + # Use Encryptor to decrypt the password + encryptor = Encryptor() + temp_password = encryptor.decrypt(principal_preference.encrypted_pass) + + # updating password + principal_obj.password = make_password(temp_password) + principal_obj.save() + + principal_preference.is_transferred = True + principal_preference.save() + + email_service.load_template( + "accounts/customer/account_transfer_email_template.html", locals() + ) + email_service.send() + messages.success(request, "Account Transfer mail send successfully") + except Exception as e: + messages.error(request, f"{str(e)}") + + return redirect(reverse_lazy("accounts:customer_detail", kwargs={"pk": kwargs.get("pk")})) + + +class CustomerImportView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_CUSTOMER + resource = resource_action.RESOURCE_MANAGE_CUSTOMER + action = resource_action.ACTION_READ + template_name = "accounts/customer/customer_bulk_template.html" + form_class = UploadExcelForm + + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def validate_date(self, date_str, row_num, error_log, field_name): + """function to validate the date format DD-MM-YYYY""" + # Check if the input is already a datetime object + if isinstance(date_str, datetime): + return date_str + + # If it's a string, attempt to validate it + if isinstance(date_str, str): + try: + return datetime.strptime(date_str, '%d-%m-%Y') + except ValueError: + error_log.append(f"Row {row_num}: {field_name} '{date_str}' is not in the format DD-MM-YYYY.") + return None + + # If it's neither a string nor a datetime object, log an error + error_log.append(f"Row {row_num}: {field_name} '{date_str}' is of invalid type.") + return None + + def validate_phone(self, phone_str, row_num, error_log): + """Helper function to validate phone number""" + try: + phone_number = phonenumbers.parse(phone_str, None) + if not phonenumbers.is_valid_number(phone_number): + error_log.append(f"Row {row_num}: Phone number '{phone_str}' is not valid.") + return None + return phone_number + except Exception as e: + error_log.append(f"Row {row_num}: Phone number '{phone_str}' could not be parsed.") + return None + + def get(self, request, *args, **kwargs): + form = self.form_class() + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + form = self.form_class(request.POST, request.FILES) + context = self.get_context_data(form=form) + if not form.is_valid(): + print(form.errors) + return render(request, self.template_name, context=context) + + excel_file = request.FILES['file'] + + wb = load_workbook(filename=excel_file) + + # Check if the specific sheet exists + if 'Manager Onboarding' not in wb.sheetnames: + form.add_error('file', 'The required sheet "Manager Onboarding" is not present in the uploaded file.') + return render(request, self.template_name, context=context) + + # Load the "Manager Onboarding" worksheet + ws = wb['Manager Onboarding'] + + error_log = [] + + principals = [] + preferences_list = [] + subscriptions = [] + principal_type = IAmPrincipalType.objects.get(name=resource_action.PRINCIPAL_TYPE_EVENT_MANAGER) + free_subscription = Subscription.objects.filter(is_free=True, active=True).first() + + if not free_subscription: + messages.error(self.request, "Create a free subscription record for admin in manage subscription") + return render(request, self.template_name, context=context) + + for idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + first_name, last_name, business_name, email, phone_no, preferences, start_date, end_date, address, region, country, website, linkedin, facebook, instagram, twitter = row + print(f"{first_name}, {last_name, email, preferences, start_date, end_date}") + + # validate all data + if not first_name or not last_name or not email or not preferences or not start_date or not end_date: + error_log.append(f"Row {idx}: Missing data.") + continue + + # validate email existence + if IAmPrincipal.objects.filter(email=email).exists(): + error_log.append(f"Row {idx}: Email {email} already exists.") + continue + + # Validate start_date and end_date formats + start_date = self.validate_date(start_date, idx, error_log, 'Start Date') + end_date = self.validate_date(end_date, idx, error_log, 'End Date') + + if not start_date or not end_date: + continue # Skip if dates are invalid + + # validate date rnage + if end_date < start_date: + error_log.append(f"Row {idx}: End date {end_date} must greater then start date {start_date}.") + continue + + # Validate phone number + if phone_no: + phone_number = self.validate_phone(str(phone_no), idx, error_log) + if not phone_number: + continue # Skip if phone number is invalid + + # validate preferences + preference_list = [pref.strip() for pref in preferences.split(',')] + event_categories = EventCategory.objects.filter(title__in=preference_list) + if len(event_categories) != len(preference_list): + error_log.append(f"Row {idx}: One or more preferences are invalid.") + continue + + random_password = IAmPrincipal.generate_random_password() + + # Encrypt the password + encryptor = Encryptor() + encrypted_password = encryptor.encrypt(random_password) + + # collect the principals + principal = IAmPrincipal( + first_name=first_name.strip().capitalize(), + last_name=last_name.strip().capitalize(), + email=email.strip(), + password=make_password(random_password), + username=email.strip(), + email_verified=True, + register_complete=True, + principal_type=principal_type, + business_name=business_name, + phone_no=phone_no, + address_line1=address, + city=region, + country=country, + website=website, + linkedin_profile=linkedin, + facebook_profile=facebook, + instagram_profile=instagram, + twitter_profile=twitter + ) + principals.append(principal) + + # Collect preferences to be set later + preferences_list.append((principal, event_categories, start_date, end_date)) + + if error_log: + context = self.get_context_data(form=form, error_log=error_log) + messages.error(request, "No record is created check error log and fix the error in the file ") + return render(request, self.template_name, context=context) + + # Use transaction.atomic to ensure all-or-nothing + with transaction.atomic(): + # Bulk create principals + for principal in principals: + principal.save() + + # Now we need to refresh principals from the DB to get their IDs + principals = IAmPrincipal.objects.filter(email__in=[p.email for p in principals]) + + # Create subscriptions and preferences + for principal, event_categories, start_date, end_date in preferences_list: + principal = principals.get(email=principal.email) + + # Generate referral code for the manager + ReferralCode.create_referral_code_for_user_manager(principal=principal, principal_type=principal_type) + + # Create IAmPrincipalExtendedData record + IAmPrincipalExtendedData.objects.create(principal=principal, is_onboarded=True,encrypted_pass=encrypted_password,) + + # Create PrincipalSubscription record + subscription = PrincipalSubscription( + principal=principal, + start_date=start_date, + end_date=end_date, + grace_period_end_date=PrincipalSubscription.generate_grace_period_end_date(end_date), + is_paid=True, + subscription=free_subscription + ) + subscriptions.append(subscription) + + # Create PrincipalPreferences record + preference = PrincipalPreference(principal=principal) + preference.save() + preference.preferred_categories.set(event_categories) + + # Bulk create subscriptions + PrincipalSubscription.objects.bulk_create(subscriptions) + + messages.success(request, "Data imported successfully") + return render(request, self.template_name, context=context) + + +class CustomerExportView(LoginRequiredMixin, generic.View): + model = IAmPrincipal + + def get(self, request, *args, **kwargs): + princiapls = IAmPrincipal.objects.select_related("extended_data").filter(principal_type__name=resource_action.PRINCIPAL_TYPE_EVENT_MANAGER) + + # prepare data for excel file + data = [] + for principal in princiapls: + data.append([ + principal.email, + principal.first_name, + principal.last_name, + str(principal.phone_no) if principal.phone_no else "N/A", + principal.email_verified, + principal.is_active, + # principal.extended_data.is_onboarded if principal.extended_data else 'N/A', + # principal.extended_data.is_transferred if principal.extended_data else 'N/A', + # principal.created_on.replace(tzinfo=None) if principal.created_on else 'N/A' + ]) + + # Define the columns for the Excel file + columns = ["Email", "First Name", "Last Name", "Phone Number", "Email Verified", "Active"] + + # Create a workbook and select the active worksheet + wb = Workbook() + ws = wb.active + ws.title = "Event Managers List" + + # Write the column headers + for col_num, column_title in enumerate(columns, 1): + cell = ws.cell(row=1, column=col_num, value=column_title) + cell.font = Font(bold=True) + + # write the data rows + + for row_num, row_data in enumerate(data, 2): + for col_num, cell_value in enumerate(row_data, 1): + ws.cell(row=row_num, column=col_num, value=cell_value) + + response = HttpResponse(content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + response['Content-Disposition'] = 'attachment; filename=event_managers.xlsx' + wb.save(response) + + return response class DatatableListView(LoginRequiredMixin, generic.ListView): pass - class PrincipalProfileView(LoginRequiredMixin, generic.ListView): page_name = resource_action.RESOURCE_MANAGE_DASHBOARD model = IAmPrincipal diff --git a/chat/consumers.py b/chat/consumers.py index 9bf2a3f..a38567a 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -1,18 +1,10 @@ -from channels.generic.websocket import AsyncWebsocketConsumer, WebsocketConsumer +from channels.generic.websocket import AsyncWebsocketConsumer import json -import django -django.setup() - -from accounts.models import IAmPrincipal from channels.exceptions import StopConsumer -from asgiref.sync import async_to_sync, sync_to_async from django.utils import timezone from chat.models import ChatGroup, ChatMessage from channels.db import database_sync_to_async -from django.db import close_old_connections -from rest_framework_simplejwt.tokens import AccessToken from rest_framework_simplejwt.authentication import JWTAuthentication -import threading class ChatConsumer(AsyncWebsocketConsumer): @@ -26,15 +18,6 @@ class ChatConsumer(AsyncWebsocketConsumer): print("token_key: ", token_key) self.user = await self.get_user_async(token_key) print("self.user: ", self.user) - # Start the thread to get the user object - # user_thread = threading.Thread(target=self.get_user_async, args=(token_key,)) - # user_thread.start() - - # # Wait for the thread to finish and assign the user object to the scope - # user_thread.join() - # self.scope["user"] = self.user - # print("User: ", self.scope["user"]) - # print("self.user: ", self.user) # Join room group await self.channel_layer.group_add(self.room_name, self.channel_name) diff --git a/env.example b/env.example index 7c8ebed..dc80c69 100644 --- a/env.example +++ b/env.example @@ -23,6 +23,7 @@ EMAIL_PORT= EMAIL_HOST_USER= EMAIL_HOST_PASSWORD= EMAIL_USE_TLS= +DEFAULT_FROM_EMAIL= GOOGLE_MAPS_API_KEY= diff --git a/goodtimes/asgi.py b/goodtimes/asgi.py index eccfdf2..0c06e2b 100644 --- a/goodtimes/asgi.py +++ b/goodtimes/asgi.py @@ -8,13 +8,15 @@ https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ """ import os +import django +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "goodtimes.settings") +django.setup() from django.urls import path from django.core.asgi import get_asgi_application from channels.routing import ProtocolTypeRouter, URLRouter from chat.routing import websocket_urlpatterns -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "goodtimes.settings") django_asgi_app = get_asgi_application() application = ProtocolTypeRouter( { diff --git a/goodtimes/mixins.py b/goodtimes/mixins.py new file mode 100644 index 0000000..af0d2ce --- /dev/null +++ b/goodtimes/mixins.py @@ -0,0 +1,32 @@ +from django.views import generic +from goodtimes.utils import JsonResponseUtil + + +class ActionMixin(generic.View): + model = None + + def post(self, request, *args, **kwargs): + + if self.model is None: + raise NotImplementedError("Subclasses of BaseActionView must define a 'model' attribute.") + + action = request.POST.get('action') # 'archive', 'active', or 'unarchive' + ids = request.POST.getlist('ids[]') # List of IDs to perform action on + active = request.POST.get('active') + print(f"arhive action {action} and id is {ids} and active data is {active}") + if action == 'archive': + # Update 'deleted' field to True for the selected users + self.model.objects.filter(id__in=ids).update(deleted=True, active=False) + message = 'Record archived successfully.' + elif action == 'active': + # Update 'active' field to True for the selected users + self.model.objects.filter(id__in=ids).update(active=active.capitalize()) + message = 'Record updated successfully.' + elif action == 'unarchive': + # Update 'deleted' field to False for the selected users + self.model.objects.filter(id__in=ids).update(deleted=False) + message = 'Record unarchived successfully.' + else: + return JsonResponseUtil.error(message="Invalid Action") + + return JsonResponseUtil.success(message=message) \ No newline at end of file diff --git a/goodtimes/services.py b/goodtimes/services.py index 0468a27..792e22c 100644 --- a/goodtimes/services.py +++ b/goodtimes/services.py @@ -1,4 +1,9 @@ import random +import requests +import googlemaps +import stripe +import stripe.error +import tweepy from django.conf import settings from django.core.files.uploadedfile import UploadedFile from django.core.mail import EmailMessage @@ -6,14 +11,11 @@ from django.utils.html import strip_tags import math from django.template.loader import render_to_string from django.shortcuts import get_object_or_404 +from django.db.models import Case, When from smtplib import SMTPException + from accounts.models import IAmPrincipal, IAmPrincipalOtp, IAmPrincipalType -from manage_referrals.models import ( - GoodTimeCoins, - ReferralRecord, - ReferralRecordReward, - ReferralTracking, -) + from manage_subscriptions.models import PrincipalSubscription, Subscription from manage_wallets.models import ( TransactionStatus, @@ -147,7 +149,9 @@ class SMSService: raise SMSError(message=str(e)) def create_otp(self, principal: IAmPrincipal, opt_purpose: str): - old_otp_change = IAmPrincipalOtp.objects.filter(principal=principal).update(is_used=True) + old_otp_change = IAmPrincipalOtp.objects.filter(principal=principal).update( + is_used=True + ) print("Everything Is Used..!!") otp = IAmPrincipalOtp.objects.create( principal=principal, otp_purpose=opt_purpose @@ -201,195 +205,6 @@ class SMSService: # self.send(phone_numbers, body) return otp_code - -class PaymentProcessingService: - def __init__(self, webhook_data): - self.webhook_data = webhook_data - self.event_type = webhook_data["type"] - self.charge_data = webhook_data["data"]["object"] - self.customer_id = self._get_customer_id() - self.transaction = self._get_transaction_by_id() - self.principal = self.transaction.principal - self.principal_subscription = None - - def _get_customer_id(self): - # Access the customer ID from the charge object - return self.charge_data.get("customer", None) - - def _get_transaction_by_id(self): - logger.debug("self.metadata: ", self.charge_data["metadata"]) - logger.debug("transaction_id: ", self.charge_data["metadata"]["transaction_id"]) - transaction_id = self.charge_data["metadata"]["transaction_id"] - if transaction_id: - try: - logger.debug("_get_transaction_by_id: ", transaction_id) - return Transaction.objects.get(id=int(transaction_id)) - except Transaction.DoesNotExist: - logger.error(f"Transaction ID {transaction_id} not found.") - return None - - def _get_subscription(self): - logger.debug( - "subscription_id: ", self.charge_data["metadata"]["subscription_id"] - ) - subscription_id = self.charge_data["metadata"]["subscription_id"] - if subscription_id: - try: - return Subscription.objects.get(id=int(subscription_id)) - except Subscription.DoesNotExist: - logger.error(f"Subscription ID {subscription_id} not found.") - return None - - def _create_principal_subscription(self): - order_id = self.charge_data["metadata"]["order_id"] - try: - subscription = self._get_subscription() - - subscription_days = subscription.plan.days - today = timezone.now().date() - last_date = today + timedelta(days=int(subscription_days)) - - principal_subscription = PrincipalSubscription.objects.create( - principal=self.principal, - subscription=subscription, - is_paid=True, - order_id=order_id, - start_date=today, - end_date=last_date, - grace_period_end_date=last_date + timedelta(days=15), - ) - self.principal_subscription = principal_subscription - return principal_subscription - except Subscription.DoesNotExist: - logger.error( - "SOmething Went Wrong inside _create_principal_subscription()." - ) - - return None - - def process_event(self): - if self.event_type == "checkout.session.completed": - self._handle_success() - else: - self._handle_failure() - - def _handle_success(self): - with transaction.atomic(): - self._create_principal_subscription() - self._update_transaction_success() - self._credit_referral_reward_if_applicable() - - def _credit_referral_reward_if_applicable(self): - # Step 1: Check for an existing, completed referral record - referral_record = ReferralRecord.objects.filter( - active=True, - deleted=False, - referred_principal_id=self.principal.id, - is_completed=True, - ).first() - - if referral_record: - # Step 2: Check for an active subscription of the referrer - today = timezone.now().date() - active_subscription = ( - PrincipalSubscription.objects.filter( - principal=referral_record.referrer_principal, - is_paid=True, - end_date__gte=today, - cancelled=False, - deleted=False, - ) - .order_by("-end_date") - .first() - ) - if active_subscription: - subscription = self._get_subscription() - if subscription: - # Calculate the reward value - percentage = ( - subscription.referral_percentage * subscription.amount / 100 - ) - - # Create a reward entry - ReferralRecordReward.objects.create( - referral_record=referral_record, - subscription=subscription, - coins=1, # Assuming this is a default or a calculated value - value=percentage, - ) - - self._credit_good_time_coin( - referral_record.referrer_principal, percentage - ) - # Here's where you call _update_reward - self._update_reward( - referral_record=referral_record, - active_subscription=active_subscription, - create_subscription_method=self.principal_subscription, - has_active_subscription=True, - ) - else: - # If there is no active subscription, still need to update reward without active_subscription - self._update_reward( - referral_record=referral_record, - active_subscription=None, - create_subscription_method=self.principal_subscription, - has_active_subscription=False, - ) - - def _credit_good_time_coin(self, referrer_principal, percentage): - # wallet, created = Wallet.objects.get_or_create(principal=referrer_principal) - # wallet.coins += 1 - # wallet.save() - Transaction.objects.create( - principal=referrer_principal, - transaction_type=TransactionType.CREDIT, - payment_method="", - transaction_status=TransactionStatus.SUCCESS, - amount=percentage, - coins=1, - comment="Referral reward", - # Populate other fields as necessary, such as `order_id`, `product_id`, or `reference_id` if applicable - ) - - def _handle_failure(self): - # Implement any necessary logic to handle a failed payment - self._update_transaction_failure() - - def _update_reward( - self, - referral_record, - active_subscription, - create_subscription_method, - has_active_subscription, - ): - # Check if the referrer has an active subscription and get its ID if it exists - referrer_subscription_id = ( - active_subscription.id if active_subscription else None - ) - - # Create a new subscription for the referred principal - referred_subscription_id = self.principal_subscription.id - - # Create or update the ReferralTracking record - ReferralTracking.objects.create( - referral_record=referral_record, - referrer_subscription_id=referrer_subscription_id, - referred_subscription_id=referred_subscription_id, - is_referrer_subscribed=has_active_subscription, - ) - - def _update_transaction_success(self): - principal_subscription = self.principal_subscription - self.transaction.transaction_status = TransactionStatus.SUCCESS - self.transaction.principal_subscription = principal_subscription - self.transaction.save() - - def _update_transaction_failure(self): - self.transaction.transaction_status = TransactionStatus.FAIL - self.transaction.save() - - class InteractionCalculator: def __init__(self, event): self.event = event @@ -436,19 +251,22 @@ class InteractionCalculator: class EventFilterService: + today = timezone.now().date() + one_week_ago = today - timedelta(days=7) + + # Base query for events that are active, not deleted, not draft, created by active users, and visible up to 1 week after their end date + base_event_query = Event.objects.filter( + active=True, + deleted=False, + draft=False, + end_date__gte=one_week_ago, + created_by__is_active=True, + ).distinct() + @staticmethod def filter_events_by_search(search_query=None): - today = timezone.now().date() - # Filter events that are active, not deleted, not draft, and created by active users - filtered_events = Event.objects.filter( - deleted=False, - active=True, - draft=False, - created_by__is_active=True, - end_date__gte=today, # Only include events that end today or in the future - ) + filtered_events = EventFilterService.base_event_query - # Optional search filtering on title, key_guest, venue address, and tags if search_query: print("search_query: ", search_query) filtered_events = filtered_events.filter( @@ -458,136 +276,80 @@ class EventFilterService: | Q(tags__name__icontains=search_query) ) print("filtered_events: ", filtered_events) + else: + # Filter events where key_guest is not null if no search query is provided + filtered_events = filtered_events.filter( + Q(key_guest__isnull=False) & ~Q(key_guest__exact="") + ) - # Ensure results are distinct - filtered_events = filtered_events.distinct() - - return filtered_events + return filtered_events.distinct() @staticmethod def filter_events_by_tags(search_query=None): - today = timezone.now().date() + filtered_events = EventFilterService.base_event_query + if search_query: print("search_query: ", search_query) - filtered_events = Event.objects.filter( + filtered_events = filtered_events.filter( tags__isnull=False, - deleted=False, - active=True, - draft=False, - created_by__is_active=True, tags__name__icontains=search_query, ) - - # filtered_events = ( - # filtered_events.annotate( - # matched_tags=Count( - # "tags", - # filter=Q(tags__name__icontains=search_query), - # distinct=True, - # ) - # ) - # .filter(matched_tags__gt=0) - # .distinct() - # ) print("filtered_events: ", filtered_events) - # Filter for current, future, or ongoing events - current_and_future_events_query = Q( - start_date__lte=today, end_date__gte=today - ) | Q(start_date__gt=today) - filtered_events = filtered_events.filter(current_and_future_events_query) - - return filtered_events + return filtered_events.distinct() @staticmethod def filter_events(filter_type, principal=None): - today = timezone.now().date() - events = Event.objects.none() - - current_and_future_events_query = Q( - active=True, deleted=False, draft=False, created_by__is_active=True - ) & (Q(start_date__lte=today, end_date__gte=today) | Q(start_date__gt=today)) + events = EventFilterService.base_event_query if filter_type == "expensive": - events = Event.objects.filter(current_and_future_events_query).order_by( - "-entry_fee" - ) + events = events.order_by("-entry_fee") elif filter_type == "cheap": - events = Event.objects.filter(current_and_future_events_query).order_by( - "entry_fee" - ) + events = events.order_by("entry_fee") elif filter_type == "preference" and principal is not None: preferences = PrincipalPreference.objects.get(principal=principal) preferred_categories_ids = preferences.preferred_categories.values_list( "id", flat=True ) - events = Event.objects.filter( - category__in=preferred_categories_ids, - end_date__gte=today, - draft=False, - active=True, - deleted=False, - created_by__is_active=True, - ).distinct() + events = events.filter(category__in=preferred_categories_ids) - return events + return events.distinct() @staticmethod def filter_events_by_category(category_id): - today = timezone.now().date() + events = EventFilterService.base_event_query - current_and_future_events_query = Q( - active=True, deleted=False, draft=False, created_by__is_active=True - ) & (Q(start_date__lte=today, end_date__gte=today) | Q(start_date__gt=today)) - - # Ensure the category_id is valid and within the specified range (1-8) + # Ensure the category_id is valid and within the specified range (1-10) if 1 <= category_id <= 10: - events = Event.objects.filter( - current_and_future_events_query, category_id=category_id - ).distinct() + events = events.filter(category_id=category_id) else: events = ( Event.objects.none() ) # Return an empty queryset if the category_id is not valid - return events + return events.distinct() @staticmethod def filter_events_for_tomorrow(): - today = timezone.now().date() - tomorrow = today + timezone.timedelta(days=1) + tomorrow = EventFilterService.today + timezone.timedelta(days=1) - # Events that are starting tomorrow, ending tomorrow, or have an end date greater than tomorrow - events_query = ( - Q(start_date=tomorrow) - | Q(end_date=tomorrow) - | (Q(start_date__lte=tomorrow) & Q(end_date__gte=tomorrow)) + events = EventFilterService.base_event_query.filter( + start_date__lte=tomorrow, + end_date__gte=tomorrow, ) - events = Event.objects.filter( - events_query, - active=True, - deleted=False, - draft=False, - created_by__is_active=True, - ).distinct() - return events + return events.distinct() @staticmethod def filter_events_for_today(): - today = timezone.now().date() - print("Today: ", today) + print("Today: ", EventFilterService.today) - events = Event.objects.filter( - active=True, - deleted=False, - draft=False, - start_date__lte=today, - end_date__gte=today, - created_by__is_active=True, + events = EventFilterService.base_event_query.filter( + start_date__lte=EventFilterService.today, + end_date__gte=EventFilterService.today, ) - return events + return events.distinct() # ye package ka naam hai @@ -735,3 +497,648 @@ class MyEventFilterService: ) return events + + +class GoogleMapsservice: + def __init__(self, api_key=None): + self.api_key = api_key or settings.GOOGLE_MAPS_API_KEY + self.client = googlemaps.Client(key=self.api_key) + + def get_distance_matrix(self, origin: list, destination: list): + """ + Get the distance matrix from Google Maps API for the given origins and destinations. + + Args: + origins (list): List of origin coordinates (latitude, longitude). + destinations (list): List of destination coordinates (latitude, longitude). + + Returns: + dict: Distance matrix response from Google Maps API. + """ + return self.client.distance_matrix(origin, destination) + + def search_address(self, address): + """ + Search for a list of addresses matching the given address string. + + :param address: Address string to search for + :return: List of matching addresses + """ + geocode_result = self.client.geocode(address) + return geocode_result + + def get_coordinates_from_address(self, address): + """ + Get the coordinates (latitude and longitude) of the given address. + + :param address: Address string to get coordinates for + :return: Coordinates as a tuple (latitude, longitude) + """ + geocode_result = self.client.geocode(address) + if geocode_result: + location = geocode_result[0]['geometry']['location'] + return location['lat'], location['lng'] + return None + + def get_place_id_from_address(self, address): + """ + Get the place ID of the given address. + + :param address: Address string to get the place ID for + :return: Place ID + """ + geocode_result = self.client.geocode(address) + if geocode_result: + return geocode_result[0]['place_id'] + return None + + def get_place_id_from_coordinates(self, latitude, longitude): + """ + Get the place ID of the given coordinates. + + :param latitude: Latitude of the location + :param longitude: Longitude of the location + :return: Place ID + """ + reverse_geocode_result = self.client.reverse_geocode((latitude, longitude)) + if reverse_geocode_result: + return reverse_geocode_result[0]['place_id'] + return None + + def search_addresses_containing(self, keyword): + """ + Search for a list of addresses containing the given keyword. + + :param keyword: Keyword to search for in addresses + :return: List of matching addresses containing the keyword + """ + geocode_result = self.client.geocode(keyword) + matching_addresses = [result['formatted_address'] for result in geocode_result if keyword.lower() in result['formatted_address'].lower()] + return matching_addresses + + def get_nearest_events(self, queryset, latitude, longitude, radius_km=10): + """ + Filter and sort events by their distance to the given latitude and longitude. + + Args: + queryset (QuerySet): The queryset of events to filter and sort. + latitude (float): The latitude of the origin point. + longitude (float): The longitude of the origin point. + radius_km (int): The radius in kilometers within which to filter events. + + Returns: + QuerySet: The filtered and sorted queryset of events. + """ + origins = [(latitude, longitude)] + + # Create a list of destination coordinates and map them to the events + destinations_and_events = [ + ((event.venue.latitude, event.venue.longitude), event) + for event in queryset + if event.venue.latitude and event.venue.longitude + ] + + if not destinations_and_events: + return queryset + + # Batch size for Google Distance Matrix API (max 25 elements) + batch_size = 25 + distances = {} + + # Loop through batches of destinations + for i in range(0, len(destinations_and_events), batch_size): + batch = destinations_and_events[i:i + batch_size] + print(f"batch list count is {len(batch)}") + destinations = [coords for coords, _ in batch] + events_in_batch = [event for _, event in batch] + + # Call the Google Maps API for the current batch + matrix = self.get_distance_matrix(origins, destinations) + + # Extract distances and associate them with events + for event, element in zip(events_in_batch, matrix["rows"][0]["elements"]): + if element["status"] == "OK" and element["distance"]["value"] <= radius_km * 1000: # Convert km to meters + distances[event.id] = element["distance"]["value"] + + if not distances: + return queryset.none() + + # Filter the queryset to include only events within the specified radius + queryset = queryset.filter(id__in=distances.keys()) + + # Sort the event IDs by their distances in ascending order + event_ids_by_distance = sorted(distances, key=distances.get) + + # Create a Case/When expression to preserve the order of events by distance + preserved_order = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(event_ids_by_distance)]) + + # Order the queryset based on the preserved order + queryset = queryset.order_by(preserved_order) + + return queryset + + +class TwitterAPI: + def __init__(self): + self.api_key = settings.TWITTER_API_KEY + self.api_secret_key = settings.TWITTER_API_SECRET_KEY + self.access_token = settings.TWITTER_ACCESS_TOKEN + self.access_token_secret = settings.TWITTER_ACCESS_TOKEN_SECRET + self.client, self.api = self._setup_api() + + def _setup_api(self): + client = tweepy.Client( + consumer_key=self.api_key, + consumer_secret=self.api_secret_key, + access_token=self.access_token, + access_token_secret=self.access_token_secret, + ) + auth = tweepy.OAuth1UserHandler( + self.api_key, + self.api_secret_key, + self.access_token, + self.access_token_secret, + ) + api = tweepy.API(auth, wait_on_rate_limit=True) + return client, api + + def post_text_tweet(self, caption): + tweet = self.client.create_tweet(text=caption) + return tweet + + def post_image_with_caption(self, image_url, caption): + media = self.api.media_upload(image_url) + tweet = self.client.create_tweet(text=caption, media_ids=[media.media_id]) + return tweet + + +class TwitterPoster: + def __init__(self, twitter_api): + self.twitter_api = twitter_api + + def post_text_tweet(self, caption): + try: + tweet = self.twitter_api.post_text_tweet(caption) + return {'success': True, 'message': 'Tweet posted successfully!'} + except tweepy.TweepyException as e: + return {'success': False, 'message': f'Error posting tweet: {e}'} + + def post_image_with_caption(self, image_url, caption): + try: + tweet = self.twitter_api.post_image_with_caption(image_url, caption) + return {'success': True, 'message': 'Tweet posted successfully!'} + except tweepy.TweepyException as e: + return {'success': False, 'message': f'Error posting tweet: {e}'} + +class FacebookAPI: + def __init__(self): + self.app_id = settings.FACEBOOK_APP_ID + self.app_secret = settings.FACEBOOK_APP_SECRET + self.page_id = settings.FACEBOOK_PAGE_ID + self.graph_api_version = settings.FACEBOOK_GRAPH_VERSION_API + self.access_token = settings.FACEBOOK_ACCESS_TOKEN # long live access token + + def post_photo(self, image_url, caption): + if not self.access_token: + print("Page access token not obtained. Call authenticate() first.") + return False + try: + url = f"https://graph.facebook.com/{self.graph_api_version}/{self.page_id}/photos" + params = { + "message": caption, + "url": image_url, + "access_token": self.access_token, + } + response = requests.post(url, params=params) + # response.raise_for_status() + result = response.json() + if "id" not in result: + print(f"Error posting photo: {result}") + return False + print(f"Data posted successfully. Post Id: {result['id']}") + return True + except Exception as e: + print(f"Error posting photo: {e}") + return False + +class FacebookPoster: + def __init__(self, facebook_api): + self.facebook_api = facebook_api + + def post_photo(self, image_url, caption): + result = self.facebook_api.post_photo(image_url, caption) + if not result: + return {'success': False, 'message': 'Error posting photo in Facebook'} + return {'success': True, 'message': 'Photo posted successfully'} + + + +# import requests + +# app_id = "YOUR_APP_ID" +# app_secret = "YOUR_APP_SECRET" +# page_id = "YOUR_PAGE_ID" # You need to specify the page ID +# # Step 1: Get an App Access Token +# response = requests.get(f"https://graph.facebook.com/oauth/access_token?client_id={app_id}&client_secret={app_secret}&grant_type=client_credentials") +# app_access_token = response.json()["access_token"] + +# # Step 2: Get a Page Access Token +# response = requests.get(f"https://graph.facebook.com/{page_id}?fields=access_token&access_token={app_access_token}") +# page_access_token = response.json()["access_token"] + +# # Use the Page Access Token to query the Page node +# response = requests.get(f"https://graph.facebook.com/{page_id}?access_token={page_access_token}") +# page_data = response.json() +# print(page_data) +class InstagramAPI: + def __init__(self): + self.app_id = settings.FACEBOOK_APP_ID + self.app_secret = settings.FACEBOOK_APP_SECRET + self.page_id = settings.INSTAGRAM_PAGE_ID + self.page_access_token = None + + def _get_short_lived_user_access_token(self): + try: + url = f"https://graph.facebook.com/oauth/access_token" + params = { + "grant_type": "client_credentials", + "client_id": self.app_id, + "client_secret": self.app_secret + } + response = requests.get(url, params=params) + response.raise_for_status() + print(f"Short-lived token: {response.json()}") + return response.json()['access_token'] + except requests.exceptions.RequestException as e: + print(f"Error getting short-lived user access token: {e}") + return None + + def _get_long_lived_user_access_token(self, short_lived_token): + try: + url = f"https://graph.facebook.com/v20.0/oauth/access_token" + params = { + "grant_type": "fb_exchange_token", + "client_id": self.app_id, + "client_secret": self.app_secret, + "fb_exchange_token": short_lived_token + } + response = requests.get(url, params=params) + response.raise_for_status() + print(f"Long-lived access token: {response.json()}") + return response.json()['access_token'] + except requests.exceptions.RequestException as e: + print(f"Error getting long-lived user access token: {e}") + return None + + def _get_page_access_token(self, long_lived_token): + try: + url = f"https://graph.facebook.com/{self.page_id}" + params = { + "fields": "access_token", + "access_token": long_lived_token + } + response = requests.get(url, params=params) + response.raise_for_status() + print(f"Page access token: {response.json()}") + return response.json()["access_token"] + except Exception as e: + print(f"Error getting page access token: {e}") + return None + + def authenticate(self): + # short_lived_token = self._get_short_lived_user_access_token() + # if not short_lived_token: + # return False + # long_lived_token = self._get_long_lived_user_access_token(short_lived_token) + # if not long_lived_token: + # return False + # self.page_access_token = self._get_page_access_token(long_lived_token) + self.page_access_token = settings.FACEBOOK_ACCESS_TOKEN + return True + + def post_image_with_caption(self, image_path, caption): + if not self.page_access_token: + print("Page access token not obtained. Call Authenticate() first.") + return False + try: + url = f"https://graph.facebook.com/v20.0/{self.page_id}/media" + params = { + "caption": caption, + "image_url": image_path, + "access_token": self.page_access_token + } + response = requests.post(url, data=params) + # response.raise_for_status() + result = response.json() + print(f"Post image with caption result: {result}") + + url = f"https://graph.facebook.com/v20.0/{self.page_id}/media_publish" + params = { + "creation_id": result["id"], + "access_token": self.page_access_token + } + response = requests.post(url, params=params) + # response.raise_for_status() + result = response.json() + return True + except Exception as e: + print(f"Error posting photo on instagram: {e}") + return False + + +class InstagramPoster: + def __init__(self, instagram_api): + self.instagram_api = instagram_api + + def post_image_with_caption(self, image_path, caption): + if not self.instagram_api.authenticate(): + print("Instagram API authentication failed.") + return {'success': False, 'message': 'Error posting photo. Authenticate failed'} + result = self.instagram_api.post_image_with_caption(image_path, caption) + if not result: + return {'success': False, 'message': 'Error posting photo in Instagram.'} + return {'success': True, 'message': 'Photo posted successfully'} + + +class StripeService: + stripe.api_key = settings.STRIPE_SECRET_KEY + + @staticmethod + def create_product(name: str, description: str = None, metadata: dict = None): + """ + Create a Stripe Product. + + :param name: Name of the product, meant to be displayable to the customer. + :param description: An optional description of the product. + :param metadata: An optional dictionary of key-value pairs to attach to the product. + :return: The created Stripe product object. + + See: https://docs.stripe.com/api/products/create?lang=python + """ + try: + product = stripe.Product.create(name=name, description=description, metadata=metadata) + return {'success': True, 'data': product} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error creating product: {e}"} + + @staticmethod + def retrive_product(product_id: str): + """ + Retrieve a Stripe Product by its ID. + + :param product_id: The ID of the product to retrieve. + :return: The retrieved Stripe Product object. + + See: https://docs.stripe.com/api/products/update?lang=python + """ + try: + product = stripe.Product.retrieve(product_id) + return {'success': True, 'data': product} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error retriving product: {e}"} + + @staticmethod + def update_product(product_id: str, **kwargs): + """ + Update a Stripe Product by its ID. + + :param product_id: The ID of the product to update. + :param kwargs: Optional paramters to update the product, such as: + - name : The new name of the product. + - description : The new description of the product. + - active : A boolean flag indicating if the product is active. + - metadata : A dictionary of key-value pairs to attach to the product. + :return: The updated Stripe Product object. + + See: https://docs.stripe.com/api/products/update?lang=python + """ + try: + product = stripe.Product.modify(product_id, **kwargs) + return {'success': True, 'data': product} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error updating product: {e}"} + + @staticmethod + def delete_product(product_id: str): + """ + Delete a Stripe Product by its ID. + + :param product_id: ID of the product to delete. + :return: The deleted Stripe Product object. + + See: https://docs.stripe.com/api/products/delete?lang=python + """ + try: + product = stripe.Product.delete(product_id) + return {'success': True, 'data': product} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error deleting product: {e}"} + + @staticmethod + def create_price(product_id: str = None, product_data: dict = None, unit_amount: int = None, currency: str = 'gbp', recurring: dict = None, metadata: dict = None): + """ + Create a Stripe Price for a product. + + :param product_id: ID of the product for which the price is being created. + :param product_data: A dictionary with product details to create a new product on the fly. Example: + - name : The name of the product. + - description : The description of the product. + :param unit_amount: The amount to be charged.(in cents) + :param currency: The currency of the price. + :param recurring: A dictionary with recurring pricing details. Example: + - interval : The interval at which the price is charged (e.g., 'day', 'week', 'month', 'year'. + - interval_count : The number of intervals at which the price is charged. + :param metadata: An optional dictionary of key-value pairs to attach to the price. + :return: The created Stripe Price object. + :raise ValueError: If neither product_id nor product_data is provided. + + See: https://docs.stripe.com/api/prices/create?lang=python + """ + if not product_id and not product_data: + raise ValueError("Either product_id or product_data must be provided to create a price.") + + price_data = { + 'unit_amount': unit_amount, + 'currency': currency, + 'recurring': recurring, + 'metadata': metadata + } + + if product_id: + price_data['product'] = product_id + elif product_data: + price_data['product'] = stripe.Product.create(**product_data).id + try: + price = stripe.Price.create(**price_data) + return {'success': True, 'data': price} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error creating price: {e}"} + + @staticmethod + def retrieve_price(price_id: str): + """ + Retrieve a Stripe Price by its ID. + :param price_id: ID of the price to retrive + :return: The retrieved Stripe Price object + + See: https://docs.stripe.com/api/prices/retrieve?lang=python + """ + try: + price = stripe.Price.retrieve(price_id) + return {'success': True, 'data': price} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error retrieving price: {e}"} + + @staticmethod + def update_price(price_id: str, **kwargs): + """ + Update a Stripe Price by its ID. + :param price_id: ID of the price to update + :param kwargs: Optional parameters to update the price, such as: + - active: A boolean flag indicating if the price is active. + - nickname: A nickname for the price, useful for labeling and organizing. + - metadata: A set of key-value pairs to attach to the price object. + :return: The updated Stripe Price object + + See: https://docs.stripe.com/api/prices/update?lang=python + """ + try: + price = stripe.Price.modify(price_id, **kwargs) + return {'success': True, 'data': price} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error updating price: {e}"} + + # stipe not provide to delete the price + + @staticmethod + def create_coupon( + amount_off: int = None, + percent_off: float = None, + duration: str = "once", + name: str = None, + currency: str = None, + redeem_by: datetime = None, + max_redemptions: int = 0, + metadata: dict = None + ) -> dict: + """ + Creates a Stripe Coupon with either a fixed amount off or a percentage off. + + :param amount_off: The discount amount to be applied (in the smallest currency unit, e.g., cents). This cannot be used in conjunction with `percent_off`. + :param percent_off: The discount percentage to be applied to the price. This cannot be used in conjunction with `amount_off`. + :param duration: The duration for which the coupon is valid. Valid values are: + - "once": The coupon will apply to the next invoice only. + :param name: An optional name for the coupon. + :param currency: The currency in which the `amount_off` is specified. Required if `amount_off` is used. + :param redeem_by: A timestamp at which the coupon will no longer be redeemable. + The coupon can still be applied to invoices created after the `redeem_by` date, + if the subscription was active prior to the date. + :param max_redemptions: The maximum number of times this coupon can be redeemed in total. + Defaults to 0, meaning unlimited redemptions. + :param metadata: A set of key-value pairs to store additional information about the coupon in Stripe. + + :return: A dictionary containing: + - 'success': Boolean indicating the success of the operation. + - 'data': The created Stripe Coupon object if successful. + - 'message': Error message if the operation failed. + + :raises ValueError: If both `amount_off` and `percent_off` are provided, or if neither is provided. + Also raised if `amount_off` is provided without a corresponding `currency`. + :raises stripe.error.StripeError: If an error occurs while creating the coupon via the Stripe API. + + See: https://docs.stripe.com/api/coupons/create?lang=python + """ + if amount_off and percent_off: + raise ValueError("You can provide either `amount_off` or `percent_off`, but not both.") + + if not amount_off and not percent_off: + raise ValueError("You must provide either `amount_off` or `percent_off`.") + + if amount_off and not currency: + raise ValueError("Currency must be provided when `amount_off` is specified.") + + coupon_data = { + "duration": duration, + "name": name, + "redeem_by": redeem_by, + "max_redemptions": max_redemptions, + "metadata": metadata, + } + + if amount_off: + coupon_data.update({ + "amount_off": amount_off, + "currency": currency, + }) + elif percent_off: + coupon_data.update({ + "percent_off": percent_off, + }) + + try: + coupon = stripe.Coupon.create(**coupon_data) + return {'success': True, 'data': coupon} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error creating coupon: {e}"} + + @staticmethod + def retrieve_coupon(coupon_id: str): + """ + Retrieve a Stripe Coupon by its ID. + + :param coupon_id: The ID of the coupon to retrieve. + :return: The retrieved Stripe Coupon object. + + See: https://docs.stripe.com/api/coupons/retrieve?lang=python + """ + try: + coupon = stripe.Coupon.retrieve(coupon_id) + return {'success': True, 'data': coupon} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error retrieving coupon: {e}"} + + @staticmethod + def delete_coupon(coupon_id: str): + """ + Retrieve a Stripe Coupon by its ID. + + :param coupon_id: The ID of the coupon to retrieve. + :return: The retrieved Stripe Coupon object. + """ + try: + coupon = stripe.Coupon.delete(coupon_id) + return {'success': True, 'data': coupon} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error deleting coupon: {e}"} + + @staticmethod + def cancel_auto_renew_subscription(subscription_id: str): + """ + Cancels the auto-renewal of a Stripe subscription. + + :param subscription_id: The ID of the subscription to cancel auto-renewal for. + :return: A dictionary with success status and the updated subscription object or an error message. + """ + try: + # Update the subscription to cancel at the end of the current period + subscription = stripe.Subscription.modify( + subscription_id, + cancel_at_period_end=True + ) + return {'success': True, 'data': subscription} + except stripe.error.StripeError as e: + + return {'success': False, 'message': f'Error cancelling subscription auto-renewal: {e}'} + +from cryptography.fernet import Fernet +class Encryptor: + def __init__(self): + self.key = "paMSf3Ny8KAMs1tRLcVOQQhRxTnInHLwP7WtVdm8O_4=" + self.fernet = Fernet(self.key) + + def encrypt(self, plaintext): + return self.fernet.encrypt(plaintext.encode()).decode() + + def decrypt(self, encrypted_text): + return self.fernet.decrypt(encrypted_text.encode()).decode() + diff --git a/goodtimes/settings/base.py b/goodtimes/settings/base.py index 75e1ce9..8e96bc9 100644 --- a/goodtimes/settings/base.py +++ b/goodtimes/settings/base.py @@ -64,6 +64,7 @@ LOCAL_APPS = [ "manage_referrals", "manage_cms", "manage_communications", # for contact us, and feedback + "manage_coupons", "manage_notifications.apps.ManageNotificationsConfig", "chat", ] @@ -76,12 +77,14 @@ THIRD_PARTY_APPS = [ "taggit", "django_quill", "corsheaders", + 'django_extensions', "allauth", "allauth.account", "allauth.socialaccount", "allauth.socialaccount.providers.apple", "allauth.socialaccount.providers.google", - # "django_crontab", + "django_filters", + "django_crontab", # "django_celery_results", # "django_celery_beat", ] @@ -209,6 +212,7 @@ TIME_FORMAT = "H:i p" # otp expire time limit OTP_EXPIRE_TIME = 1 # mins +DEFAULT_CHARSET = 'utf-8' # Default primary key field type @@ -237,6 +241,7 @@ EMAIL_HOST_USER = env.str("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = env.str("EMAIL_HOST_PASSWORD") EMAIL_PORT = env.str("EMAIL_PORT") EMAIL_USE_TLS = True +DEFAULT_FROM_EMAIL = env.str("DEFAULT_FROM_EMAIL") # LOGGING @@ -301,8 +306,13 @@ SIMPLE_JWT = { "JTI_CLAIM": "jti", } + STRIPE_SECRET_KEY = env.str("STRIPE_SECRET_KEY") STRIPE_PUBLISH_KEY = env.str("STRIPE_PUBLISH_KEY") +# https://dashboard.stripe.com/webhooks/create?endpoint_location=local +# This is your Stripe CLI webhook secret for testing your endpoint locally. +ENDPOINT_SECRET = "whsec_ccf1f87295603cdd1733995ee2d3c0d6f74c7ceaf28916ea45114a54b7ce1d0f" + ONE_SIGNAL_APP_ID = env.str("ONE_SIGNAL_APP_ID") ONE_SIGNAL_API_KEY = env.str("ONE_SIGNAL_API_KEY") @@ -328,7 +338,7 @@ CHANNEL_LAYERS = { WEBSOCKET_TIMEOUT = 30 CRONJOBS = [ - # ("0 9 * * 1-5", "manage_games.cron.update_game_status_live"), + # ('0 0 * * *', 'myapp.cron.daily_task >> /path/to/logfile.log 2>&1'), ] GOOGLE_MAPS_API_KEY = env.str("GOOGLE_MAPS_API_KEY") @@ -337,3 +347,19 @@ PLACES_MAPS_API_KEY = env.str("GOOGLE_MAPS_API_KEY") PLACES_MAP_WIDGET_HEIGHT = 480 PLACES_MAP_OPTIONS = '{"center": { "lat": 38.971584, "lng": -95.235072 }, "zoom": 10}' PLACES_MARKER_OPTIONS = '{"draggable": true}' + +# twitter keys +TWITTER_API_KEY = env.str("TWITTER_API_KEY") +TWITTER_API_SECRET_KEY = env.str("TWITTER_API_SECRET_KEY") +TWITTER_ACCESS_TOKEN = env.str("TWITTER_ACCESS_TOKEN") +TWITTER_ACCESS_TOKEN_SECRET = env.str("TWITTER_ACCESS_TOKEN_SECRET") + +# facebook keys +FACEBOOK_APP_ID = env.str("FACEBOOK_APP_ID") +FACEBOOK_APP_SECRET = env.str("FACEBOOK_APP_SECRET") +FACEBOOK_PAGE_ID = env.str("FACEBOOK_PAGE_ID") +FACEBOOK_GRAPH_VERSION_API = env.str("FACEBOOK_GRAPH_VERSION_API") +FACEBOOK_ACCESS_TOKEN = env.str("FACEBOOK_ACCESS_TOKEN") + +# Instagram Key +INSTAGRAM_PAGE_ID = env.str('INSTAGRAM_PAGE_ID') diff --git a/goodtimes/settings/development.py b/goodtimes/settings/development.py index f362626..92b63e2 100644 --- a/goodtimes/settings/development.py +++ b/goodtimes/settings/development.py @@ -26,17 +26,17 @@ CORS_ORIGIN_ALLOW_ALL = True CORS_ORIGIN_WHITELIST = ("http://localhost:3000",) -if DEBUG: - MIDDLEWARE += [ - "debug_toolbar.middleware.DebugToolbarMiddleware", - ] - INSTALLED_APPS += [ - "debug_toolbar", - "django_extensions", - ] - INTERNAL_IPS = [ - "127.0.0.1", - ] +# if DEBUG: +# MIDDLEWARE += [ +# "debug_toolbar.middleware.DebugToolbarMiddleware", +# ] +# INSTALLED_APPS += [ +# "debug_toolbar", +# "django_extensions", +# ] +# INTERNAL_IPS = [ +# "127.0.0.1", +# ] BASE_DOMAIN = "http://192.168.29.219:8000" @@ -44,15 +44,14 @@ BASE_DOMAIN = "http://192.168.29.219:8000" # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ - MEDIA_URL = "/media/" MEDIA_ROOT = os.path.join(BASE_DIR, "media") -# STATIC_ROOT = os.path.join(BASE_DIR, "static") STATIC_URL = "/static/" - STATICFILES_DIRS = [BASE_DIR.joinpath("static")] -STRIPE_CHECKOUT_URL = "http://localhost:8000/subscriptions/stripe-subscription/" -STRIPE_FINAL_URL = "http://localhost:8000/subscriptions/create-checkout-session/" +STRIPE_CHECKOUT_URL = "https://deciding-firmly-fly.ngrok-free.app/subscriptions/create-checkout-session/" +COUPON_VALIDITY_CHECK_URL = "https://deciding-firmly-fly.ngrok-free.app/subscriptions/coupon-validity-check/" + +LOGO_PATH = "static" diff --git a/goodtimes/settings/production.py b/goodtimes/settings/production.py index b2413da..9efa105 100644 --- a/goodtimes/settings/production.py +++ b/goodtimes/settings/production.py @@ -6,7 +6,7 @@ from logging.handlers import TimedRotatingFileHandler DEBUG = False -ALLOWED_HOSTS = ["goodtimes.betadelivery.com", "154.41.254.33"] +ALLOWED_HOSTS = ["admin.goodtimesltd.co.uk", "77.68.29.148"] LOGGING_DIR = os.path.join( @@ -61,7 +61,7 @@ LOGGING = { } -BASE_DOMAIN = "https://goodtimes.betadelivery.com" +BASE_DOMAIN = "https://admin.goodtimesltd.co.uk" # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ @@ -77,8 +77,8 @@ STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR.joinpath("static")] STRIPE_CHECKOUT_URL = ( - "https://staging.goodtimesltd.co.uk/subscriptions/stripe-subscription/" -) -STRIPE_FINAL_URL = ( - "https://staging.goodtimesltd.co.uk/subscriptions/create-checkout-session/" + "https://admin.goodtimesltd.co.uk/subscriptions/create-checkout-session/" ) +COUPON_VALIDITY_CHECK_URL = "https://admin.goodtimesltd.co.uk/subscriptions/coupon-validity-check/" + +LOGO_PATH = "/var/www/goodtimes_prod/goodtimes/static" diff --git a/goodtimes/settings/staging.py b/goodtimes/settings/staging.py index 7d4d773..cb67fbc 100644 --- a/goodtimes/settings/staging.py +++ b/goodtimes/settings/staging.py @@ -6,61 +6,61 @@ import colorlog # from logging.handlers import TimedRotatingFileHandler DEBUG = False -ALLOWED_HOSTS = ["127.0.0.1", "77.68.8.229", "staging.goodtimesltd.co.uk"] +ALLOWED_HOSTS = ["staging.goodtimesltd.co.uk", "77.68.8.229",".staging.goodtimesltd.co.uk"] -# LOGGING_DIR = os.path.join( -# BASE_DIR, "logs" -# ) # Define the directory where log files will be stored +LOGGING_DIR = os.path.join( + BASE_DIR, "logs" +) # Define the directory where log files will be stored # Ensure the directory exists; create it if it doesn't -# if not os.path.exists(LOGGING_DIR): -# os.makedirs(LOGGING_DIR) +if not os.path.exists(LOGGING_DIR): + os.makedirs(LOGGING_DIR) -# LOGGING_LEVEL = env.str( -# "LOG_LEVEL", "INFO" -# ) # Set your desired log level (e.g., DEBUG, INFO, WARNING, ERROR) in the env file +LOGGING_LEVEL = env.str( + "LOG_LEVEL", "INFO" +) # Set your desired log level (e.g., DEBUG, INFO, WARNING, ERROR) -# LOGGING = { -# "version": 1, -# "disable_existing_loggers": False, -# "formatters": { -# "verbose": { -# "()": colorlog.ColoredFormatter, -# "format": "%(cyan)s%(asctime)s%(reset)s | %(red)s[%(levelname)8s]%(reset)s | [ %(yellow)s%(name)s.%(module)s:%(white)s%(lineno)d%(reset)s - %(green)s%(funcName)10s()%(reset)s ] --> %(message)s", -# "datefmt": "%Y-%m-%d %H:%M:%S", -# "log_colors": { -# "DEBUG": "white", -# "INFO": "green", -# "WARNING": "yellow", -# "ERROR": "red", -# "CRITICAL": "bold_red", -# }, -# "secondary_log_colors": {}, -# "style": "%", -# } -# }, -# "handlers": { -# "logfile": { -# "level": LOGGING_LEVEL, -# "class": "logging.handlers.RotatingFileHandler", -# "filename": os.path.join(LOGGING_DIR, "goodtimes_staging_error.log"), -# 'maxBytes': 5242880, # 5*1024*1024 bytes (5MB) -# "backupCount": 10, # Number of log files to keep (15 days' worth of logs) -# "formatter": "verbose", -# }, -# }, -# "loggers": { -# "django": { -# "handlers": ["logfile"], -# "level": LOGGING_LEVEL, -# "propagate": False, -# }, -# }, -# } +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "()": colorlog.ColoredFormatter, + "format": "%(cyan)s%(asctime)s%(reset)s | %(red)s[%(levelname)8s]%(reset)s | [ %(yellow)s%(name)s.%(module)s:%(white)s%(lineno)d%(reset)s - %(green)s%(funcName)10s()%(reset)s ] --> %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + "log_colors": { + "DEBUG": "white", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "bold_red", + }, + "secondary_log_colors": {}, + "style": "%", + } + }, + "handlers": { + "logfile": { + "level": LOGGING_LEVEL, + "class": "logging.handlers.RotatingFileHandler", + "filename": os.path.join(LOGGING_DIR, "godtimes_pro_error.log"), + "maxBytes": 5242880, # 5*1024*1024 bytes (5MB) + "backupCount": 10, # Number of log files to keep (15 days' worth of logs) + "formatter": "verbose", + }, + }, + "loggers": { + "django": { + "handlers": ["logfile"], + "level": LOGGING_LEVEL, + "propagate": False, + }, + }, +} -# BASE_DOMAIN = "https://goodtimes.betadelivery.com" +BASE_DOMAIN = "https://staging.goodtimesltd.co.uk" # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ @@ -75,9 +75,10 @@ STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR.joinpath("static")] + STRIPE_CHECKOUT_URL = ( - "https://staging.goodtimesltd.co.uk/subscriptions/stripe-subscription/" -) -STRIPE_FINAL_URL = ( "https://staging.goodtimesltd.co.uk/subscriptions/create-checkout-session/" ) +COUPON_VALIDITY_CHECK_URL = "https://staging.goodtimesltd.co.uk/subscriptions/coupon-validity-check/" + +LOGO_PATH = "/var/www/goodtimes/static" diff --git a/goodtimes/settings/wdipl.py b/goodtimes/settings/wdipl.py index fdc2f39..da93a03 100644 --- a/goodtimes/settings/wdipl.py +++ b/goodtimes/settings/wdipl.py @@ -5,7 +5,7 @@ import colorlog # from logging.handlers import TimedRotatingFileHandler -DEBUG = False +DEBUG = True ALLOWED_HOSTS = ["127.0.0.1", "goodtimes.betadelivery.com", "154.41.254.33"] @@ -60,7 +60,7 @@ ALLOWED_HOSTS = ["127.0.0.1", "goodtimes.betadelivery.com", "154.41.254.33"] # }, # } -# BASE_DOMAIN = "https://goodtimes.betadelivery.com" +BASE_DOMAIN = "https://goodtimes.betadelivery.com" # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ @@ -76,8 +76,13 @@ STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR.joinpath("static")] STRIPE_CHECKOUT_URL = ( - "https://goodtimes.betadelivery.com/subscriptions/stripe-subscription/" -) -STRIPE_FINAL_URL = ( "https://goodtimes.betadelivery.com/subscriptions/create-checkout-session/" ) +COUPON_VALIDITY_CHECK_URL = ( + "https://goodtimes.betadelivery.com/subscriptions/coupon-validity-check/" +) + +LOGO_PATH = "/var/www/goodtimes/static" + +STRIPE_TEST_MODE_SECRET_KEY = env.str("STRIPE_TEST_MODE_SECRET_KEY") +STRIPE_TEST_MODE_PUBLISH_KEY = env.str("STRIPE_TEST_MODE_PUBLISH_KEY") diff --git a/goodtimes/urls.py b/goodtimes/urls.py index 5414dc6..f1cc9b8 100644 --- a/goodtimes/urls.py +++ b/goodtimes/urls.py @@ -53,6 +53,9 @@ urlpatterns = [ path("subscriptions/", include("manage_subscriptions.urls")), path("api/subscriptions/", include("manage_subscriptions.api.urls")), + path("coupons/", include("manage_coupons.urls")), + # path("api/coupons/", include("manage_coupons.api.urls")), + path("notifications/", include("manage_notifications.urls")), path("api/notifications/", include("manage_notifications.api.urls")), @@ -60,8 +63,9 @@ urlpatterns = [ # path('api/', include("accounts.api.urls")), ] +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) if settings.DEBUG: import debug_toolbar - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += [path("__debug__/", include(debug_toolbar.urls))] diff --git a/goodtimes/utils.py b/goodtimes/utils.py index d56def8..d85f258 100644 --- a/goodtimes/utils.py +++ b/goodtimes/utils.py @@ -1,3 +1,4 @@ +from django.http import JsonResponse from rest_framework.response import Response from rest_framework.renderers import JSONRenderer from rest_framework import status @@ -40,6 +41,24 @@ class ApiResponse: # return ApiResponse.error("Validation error", errors, status_code) +class JsonResponseUtil: + """ + A utility class for creating JSON responses with a standardized format. + """ + @staticmethod + def success(message, data=None, status=200): + response_data = {"success": True, "status": status, "message": message} + if data is not None: + response_data["data"] = data + return JsonResponse(response_data, status=status) + + @staticmethod + def error(message, errors=None, status=403): + response_data = {"success": False, "status": status, "message": message} + if errors is not None: + response_data["errors"] = errors + return JsonResponse(response_data, status=status) + class RandomGenerator: @staticmethod def number(start, end): diff --git a/goodtimes/webhook.py b/goodtimes/webhook.py deleted file mode 100644 index 9b54998..0000000 --- a/goodtimes/webhook.py +++ /dev/null @@ -1,285 +0,0 @@ -from django.conf import settings -from django.db import transaction -import requests -from datetime import timedelta -from django.utils import timezone -from onesignal_sdk.client import Client as OneSignalClient -from accounts.models import IAmPrincipal, IAmPrincipalOtp, IAmPrincipalType -from manage_notifications.models import InAppNotification, NotificationCategoryChoices -from manage_referrals.models import ( - GoodTimeCoins, - ReferralRecord, - ReferralRecordReward, - ReferralTracking, -) -from django.core.exceptions import ObjectDoesNotExist -from manage_subscriptions.models import PrincipalSubscription, Subscription -from manage_wallets.models import ( - TransactionStatus, - TransactionType, - Wallet, - Transaction, -) -import logging - - -logger = logging.getLogger(__name__) - - -class NotificationService: - def __init__(self): - self.client = OneSignalClient( - app_id=settings.ONE_SIGNAL_APP_ID, rest_api_key=settings.ONE_SIGNAL_API_KEY - ) - - def send_notification(self, title, message, player_id): - if player_id is None: - print("Player ID is None, skipping notification") - return - notification_payload = { - "headings": {"en": title}, - "contents": {"en": message}, - "include_player_ids": [player_id], - } - response = self.client.send_notification(notification_payload) - return response - - def save_notification(self, principal, title, message, notification_category): - InAppNotification.objects.create( - principal=principal, - title=title, - message=message, - notification_category=notification_category, - ) - - def payment_success_notification( - self, principal, subscription, principal_subscription, amount - ): - print("payment_success_notification: ", principal.player_id) - title = "Payment Successful" - end_date = principal_subscription.end_date - message = f"Your payment for {subscription} of ${amount} was successfully processed. Your subscription is valid till {end_date}" - self.send_notification(title, message, principal.player_id) - self.save_notification(principal, title, message, NotificationCategoryChoices.TRANSACTION) - - def referral_received_notification(self, principal, amount, email): - print("referral_received_notification: ", principal.player_id) - title = "Congratulations! You got a referral G-Token." - message = f"Your referral {email} has subscribed to GoodTimesApp. You have received {amount} (£)" - self.send_notification(title, message, principal.player_id) - self.save_notification(principal, title, message, NotificationCategoryChoices.REFERRAL) - - def payment_failed_notification(self, principal, subscription, amount): - print("payment_failed_notification: ", principal.player_id) - title = "Payment Failed!" - message = f"Your payment for {subscription} of ${amount} was failed." - self.send_notification(title, message, principal.player_id) - self.save_notification(principal, title, message, NotificationCategoryChoices.TRANSACTION) - - -class WebhookService: - def __init__(self, webhook_data): - self.webhook_data = webhook_data - self.event_type = webhook_data["type"] - self.charge_data = webhook_data["data"]["object"] - - def get_event_type(self): - return self.event_type - - def get_principal(self): - principal_id = self.charge_data["metadata"]["principal"] - try: - return IAmPrincipal.objects.get(id=int(principal_id)) - except (ObjectDoesNotExist, ValueError): - logger.error(f"Invalid principal ID: {principal_id}") - raise ValueError(f"Invalid principal ID: {principal_id}") - - def get_transaction(self): - transaction_id = self.charge_data["metadata"]["transaction_id"] - try: - return Transaction.objects.get(id=int(transaction_id)) - except (ObjectDoesNotExist, ValueError): - logger.error(f"Invalid transaction ID: {transaction_id}") - raise ValueError(f"Invalid transaction ID: {transaction_id}") - - def get_subscription(self): - subscription_id = self.charge_data["metadata"]["subscription_id"] - try: - return Subscription.objects.get(id=int(subscription_id)) - except (ObjectDoesNotExist, ValueError): - logger.error(f"Invalid subscription ID: {subscription_id}") - raise ValueError(f"Invalid subscription ID: {subscription_id}") - - def get_order_id(self): - return self.charge_data["metadata"]["order_id"] - - -class ReferralRewardService: - def __init__(self, principal, principal_subscription, subscription): - self.notification_service = NotificationService() - self.principal = principal - self.principal_subscription = principal_subscription - self.subscription = subscription - - def _fetch_referral_record(self): - return ReferralRecord.objects.filter( - referred_principal=self.principal, - is_completed=True, - active=True, # Assuming 'active' is a field determining if the record is currently relevant - deleted=False, # Assuming logical deletion is handled by a 'deleted' field - ).first() - - def _check_active_subscription(self, referrer_principal): - today = timezone.now().date() - return ( - PrincipalSubscription.objects.filter( - principal=referrer_principal, - is_paid=True, - end_date__gte=today, - cancelled=False, - deleted=False, - ) - .order_by("-end_date") - .first() - ) - - def _credit_reward(self, referral_record, subscription): - amount = subscription.referral_percentage * subscription.amount / 100 - ReferralRecordReward.objects.create( - referral_record=referral_record, - subscription=subscription, - coins=1, # This value could be dynamically calculated or configured elsewhere - value=amount, - ) - self._credit_transaction(referral_record.referrer_principal, amount) - self.notification_service.referral_received_notification( - referral_record.referrer_principal, amount, self.principal.email - ) - - def _credit_transaction(self, referrer_principal, amount): - print("_credit_transaction: ", referrer_principal) - Transaction.objects.create( - principal=referrer_principal, - transaction_type=TransactionType.CREDIT, - payment_method="", - transaction_status=TransactionStatus.SUCCESS, - amount=amount, - coins=1, - comment="Referral reward", - # Populate other fields as necessary, such as `order_id`, `product_id`, or `reference_id` if applicable - ) - - def _update_reward_status(self, referral_record, active_subscription): - # Check if the referrer has an active subscription and get its ID if it exists - referrer_subscription_id = ( - active_subscription.id if active_subscription else None - ) - - # Create a new subscription for the referred principal - referred_subscription_id = self.principal_subscription.id - - is_referrer_subscribed = bool(active_subscription) - - ReferralTracking.objects.create( - referral_record=referral_record, - referrer_subscription_id=referrer_subscription_id, - referred_subscription_id=referred_subscription_id, - is_referrer_subscribed=is_referrer_subscribed, - ) - - def credit_referral_reward_if_applicable(self): - referral_record = self._fetch_referral_record() - if referral_record: - active_subscription = self._check_active_subscription( - referral_record.referrer_principal - ) - if active_subscription: - print("active_subscription: ", active_subscription) - if self.subscription: - print("self.subscription: ", self.subscription) - self._credit_reward(referral_record, self.subscription) - - self._update_reward_status(referral_record, active_subscription) - - -class SubscriptionService: - def __init__(self): - self.principal_subscription = None - - def create_principal_subscription(self, principal, subscription, order_id): - subscription_days = subscription.plan.days - today = timezone.now().date() - last_date = today + timedelta(days=subscription_days) - principal_subscription = PrincipalSubscription.objects.create( - principal=principal, - subscription=subscription, - is_paid=True, - order_id=order_id, - start_date=today, - end_date=last_date, - grace_period_end_date=last_date + timedelta(days=15), - ) - self.principal_subscription = principal_subscription - return principal_subscription - - def update_transaction_success(self, principal_transaction, principal_subscription): - principal_transaction.transaction_status = TransactionStatus.SUCCESS - principal_transaction.principal_subscription = principal_subscription - principal_transaction.save() - - def update_transaction_failure(self, principal_transaction): - principal_transaction.transaction_status = TransactionStatus.FAIL - principal_transaction.save() - - -class PaymentProcessingService: - def __init__(self, webhook_data): - self.webhook_service = WebhookService(webhook_data) - self.notification_service = NotificationService() - # Retrieve objects - self.principal = self.webhook_service.get_principal() - self.transaction = self.webhook_service.get_transaction() - self.subscription = self.webhook_service.get_subscription() - self.order_id = self.webhook_service.get_order_id() - self.subscription_service = SubscriptionService() - self.principal_subscription = None - - def process_event(self): - if self.webhook_service.get_event_type() == "checkout.session.completed": - self.handle_success() - else: - self.handle_failure() - - def handle_success(self): - with transaction.atomic(): - # Create or update the principal subscription - self.principal_subscription = ( - self.subscription_service.create_principal_subscription( - self.principal, self.subscription, self.order_id - ) - ) - print("First Part Done....!!!!!") - # Update transaction status to success - self.subscription_service.update_transaction_success( - self.transaction, self.principal_subscription - ) - print("Second Part Done....!!!!!") - # Now handle referral rewards, if applicable - referral_service = ReferralRewardService( - self.principal, self.principal_subscription, self.subscription - ) - print("Above Third Part...!!!!!!!!!!!") - referral_service.credit_referral_reward_if_applicable() - print("Third Part Done....!!!!!") - self.notification_service.payment_success_notification( - self.principal, - self.subscription, - self.principal_subscription, - self.transaction.amount, - ) - - def handle_failure(self): - self.subscription_service.update_transaction_failure(self.transaction) - # self.notification_service.payment_failed_notification( - # self.principal, self.subscription, self.transaction.amount - # ) diff --git a/goodtimes/webhook/__init__.py b/goodtimes/webhook/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/goodtimes/webhook/notification_service.py b/goodtimes/webhook/notification_service.py new file mode 100644 index 0000000..0167e9f --- /dev/null +++ b/goodtimes/webhook/notification_service.py @@ -0,0 +1,80 @@ +from onesignal_sdk.client import Client as OneSignalClient +import logging +from manage_notifications.models import ( + IAmPrincipalNotificationSettings, + InAppNotification, + NotificationCategoryChoices, +) +from django.shortcuts import get_object_or_404 +from django.conf import settings + + +logger = logging.getLogger(__name__) + + +class NotificationService: + def __init__(self): + self.client = OneSignalClient( + app_id=settings.ONE_SIGNAL_APP_ID, rest_api_key=settings.ONE_SIGNAL_API_KEY + ) + + def send_notification(self, title, message, player_id): + if player_id is None: + print("Player ID is None, skipping notification") + return + notification_payload = { + "headings": {"en": title}, + "contents": {"en": message}, + "include_player_ids": [player_id], + } + response = self.client.send_notification(notification_payload) + return response + + def save_notification(self, principal, title, message, notification_category): + InAppNotification.objects.create( + principal=principal, + title=title, + message=message, + notification_category=notification_category, + ) + + def payment_success_notification( + self, principal, subscription, principal_subscription, amount + ): + print("payment_success_notification: ", principal.player_id) + title = "Payment Successful" + end_date = principal_subscription.end_date + message = f"Your payment for {subscription} of ${amount} was successfully processed. Your subscription is valid till {end_date}" + self.send_notification(title, message, principal.player_id) + self.save_notification( + principal, title, message, NotificationCategoryChoices.TRANSACTION + ) + + def referral_received_notification(self, principal, amount, email): + print("referral_received_notification: ", principal.player_id) + title = "Congratulations! You got a referral G-Token." + message = f"Your referral {email} has subscribed to GoodTimesApp. You have received {amount} (£)" + self.save_notification( + principal, title, message, NotificationCategoryChoices.REFERRAL + ) + if not self.should_send_referral_notification(principal): + print("Referral notifications are disabled for this user") + return + self.send_notification(title, message, principal.player_id) + + def payment_failed_notification(self, principal, subscription, amount): + print("payment_failed_notification: ", principal.player_id) + title = "Payment Failed!" + message = f"Your payment for {subscription} of ${amount} was failed." + self.send_notification(title, message, principal.player_id) + self.save_notification( + principal, title, message, NotificationCategoryChoices.TRANSACTION + ) + + def should_send_referral_notification(self, principal): + notification_settings = get_object_or_404( + IAmPrincipalNotificationSettings, + principal=principal, + notification_category=NotificationCategoryChoices.REFERRAL, + ) + return notification_settings.is_enabled diff --git a/goodtimes/webhook/payment_processing_service.py b/goodtimes/webhook/payment_processing_service.py new file mode 100644 index 0000000..5b075fd --- /dev/null +++ b/goodtimes/webhook/payment_processing_service.py @@ -0,0 +1,180 @@ +from venv import logger +from django.db import transaction + +from manage_wallets.models import ( + PaymentMethod, + Transaction, + TransactionStatus, + TransactionType, +) +from .notification_service import NotificationService +from .referral_reward_service import ReferralRewardService +from .subscription_service import SubscriptionService +from .webhook_service import WebhookService + + +class PaymentProcessingService: + def __init__( + self, + webhook_data, + stripe_subscription, + current_period_start, + current_period_end, + ): + self.webhook_service = WebhookService(webhook_data) + self._order_id = None + self.notification_service = NotificationService() + self.subscription_service = SubscriptionService() + self.stripe_subscription = stripe_subscription + self.current_period_start = current_period_start + self.current_period_end = current_period_end + + @property + def charge_data(self): + """Return charge data from the webhook service.""" + return self.webhook_service.charge_data + + @property + def principal(self): + """Return the principal from the webhook service.""" + return self.webhook_service.get_principal() + + @property + def subscription(self): + """Return the subscription from the webhook service.""" + return self.webhook_service.get_subscription() + + @property + def order_id(self): + """Return the order ID from the created transaction.""" + return self._order_id + + @order_id.setter + def order_id(self, value): + """Set the order ID.""" + self._order_id = value + + @property + def coupon(self): + """Return the coupon from the webhook service.""" + return self.webhook_service.get_coupon() + + @property + def amount(self): + """Return the final amount from the webhook service.""" + return self.webhook_service.get_final_amount() + + def create_transaction(self): + """Create a transaction based on webhook data.""" + transaction = Transaction.objects.create( + principal=self.principal, + principal_subscription=None, + transaction_type=TransactionType.PAYMENT, + payment_method=PaymentMethod.CARD, + transaction_status=TransactionStatus.INITIATE, + amount=self.amount, + # order_id=self.order_id, + comment="Principal Subscription Initiated", + ) + # Save the transaction to auto-generate the order_id + transaction.save() + + # Step 1: Update the order_id in PaymentProcessingService + self.order_id = transaction.order_id + + return transaction + + def process_event(self): + """Process the webhook event.""" + try: + with transaction.atomic(): + event_type = self.webhook_service.event_type + if event_type == "invoice.payment_succeeded" and self.charge_data.get("billing_reason") == "subscription_create": + logger.info(f"Skipping event {event_type} with billing reason 'subscription_create'") + return + + txn = self.create_transaction() + + if event_type in ["checkout.session.completed", "invoice.payment_succeeded"]: + self.handle_success(txn) + elif event_type in ["checkout.session.expired", "invoice.payment_failed"]: + self.handle_failure(txn) + else: + logger.warning(f"Unknown event type {event_type}. Skipping.") + return + + except Exception as e: + logger.error(f"Unexpected error: {str(e)}") + raise + + def handle_success(self, transaction): + """Handle a successful payment.""" + try: + self.create_principal_subscription(transaction) + self.process_referral_rewards() + self.send_success_notification(transaction) + self.update_transaction_status( + transaction, + TransactionStatus.SUCCESS, + self.subscription_service.principal_subscription, + ) + except Exception as e: + self.handle_failure(transaction, error_message=str(e)) + logger.error(f"Transaction Error: {str(e)}") + raise e + + def create_principal_subscription(self, transaction): + """Create or update the principal subscription.""" + self.subscription_service.principal_subscription = ( + self.subscription_service.create_principal_subscription( + principal=self.principal, + subscription=self.subscription, + stripe_subscription=self.stripe_subscription, + order_id=transaction.order_id, + current_period_start=self.current_period_start, + current_period_end=self.current_period_end, + coupon=self.coupon, + ) + ) + print("Principal Subscription Created") + + def process_referral_rewards(self): + """Handle referral rewards.""" + referral_service = ReferralRewardService( + self.principal, + self.subscription_service.principal_subscription, + self.subscription, + ) + referral_service.credit_referral_reward_if_applicable() + print("Referral Rewards Processed") + + def send_success_notification(self, transaction): + """Send a payment success notification.""" + self.notification_service.payment_success_notification( + self.principal, + self.subscription, + self.subscription_service.principal_subscription, + transaction.amount, + ) + print("Payment Success Notification Sent") + + def handle_failure(self, transaction, error_message=None): + """Handle a failed payment.""" + self.update_transaction_status( + transaction, TransactionStatus.FAIL, error_message=error_message + ) + self.notification_service.payment_failed_notification( + self.principal, self.subscription, transaction.amount + ) + print("Payment Failure Notification Sent") + + def update_transaction_status( + self, transaction, status, principal_subscription=None, error_message=None + ): + """Update the transaction status and associate with a subscription if provided.""" + transaction.transaction_status = status + if principal_subscription: + transaction.principal_subscription = principal_subscription + if error_message: + transaction.error_message = error_message + transaction.save() diff --git a/goodtimes/webhook/referral_reward_service.py b/goodtimes/webhook/referral_reward_service.py new file mode 100644 index 0000000..31198c2 --- /dev/null +++ b/goodtimes/webhook/referral_reward_service.py @@ -0,0 +1,110 @@ +from .notification_service import NotificationService +from manage_referrals.models import ( + ReferralRecord, + ReferralRecordReward, + ReferralTracking, +) +from manage_wallets.models import Transaction, TransactionType, TransactionStatus +from django.utils import timezone +from manage_subscriptions.models import PrincipalSubscription + + +class ReferralRewardService: + def __init__(self, principal, principal_subscription, subscription): + self._notification_service = NotificationService() + self._principal = principal + self._principal_subscription = principal_subscription + self._subscription = subscription + + @property + def principal(self): + return self._principal + + @property + def principal_subscription(self): + return self._principal_subscription + + @property + def subscription(self): + return self._subscription + + @staticmethod + def _fetch_referral_record(principal): + """Fetch the referral record for the given principal.""" + return ReferralRecord.objects.filter( + referred_principal=principal, + is_completed=True, + active=True, + deleted=False, + ).first() + + @staticmethod + def _check_active_subscription(referrer_principal): + """Check if the referrer principal has an active subscription.""" + today = timezone.now().date() + return ( + PrincipalSubscription.objects.filter( + principal=referrer_principal, + is_paid=True, + end_date__gte=today, + active=True, + ) + .order_by("-end_date") + .first() + ) + + def _credit_reward(self, referral_record, subscription): + amount = subscription.referral_percentage * subscription.amount / 100 + ReferralRecordReward.objects.create( + referral_record=referral_record, + subscription=subscription, + coins=1, # This value could be dynamically calculated or configured elsewhere + value=amount, + ) + self._credit_transaction(referral_record.referrer_principal, amount) + self._notification_service.referral_received_notification( + referral_record.referrer_principal, amount, self.principal.email + ) + + def _credit_transaction(self, referrer_principal, amount): + """Create a transaction record for the referral reward.""" + print("referrer_principal: ", referrer_principal) + Transaction.objects.create( + principal=referrer_principal, + transaction_type=TransactionType.CREDIT, + payment_method="", + transaction_status=TransactionStatus.SUCCESS, + amount=amount, + coins=1, + comment="Referral reward", + ) + + def _update_reward_status(self, referral_record, active_subscription): + """Update the status of the referral reward.""" + referrer_subscription_id = ( + active_subscription.id if active_subscription else None + ) + + # Create a new subscription for the referred principal + referred_subscription_id = self.principal_subscription.id + + is_referrer_subscribed = bool(active_subscription) + + ReferralTracking.objects.create( + referral_record=referral_record, + referrer_subscription_id=referrer_subscription_id, + referred_subscription_id=referred_subscription_id, + is_referrer_subscribed=is_referrer_subscribed, + ) + + def credit_referral_reward_if_applicable(self): + """Credit referral reward if applicable based on the referral record.""" + referral_record = self._fetch_referral_record(self.principal) + if referral_record: + active_subscription = self._check_active_subscription( + referral_record.referrer_principal + ) + if active_subscription and self.subscription: + self._credit_reward(referral_record, self.subscription) + + self._update_reward_status(referral_record, active_subscription) diff --git a/goodtimes/webhook/subscription_service.py b/goodtimes/webhook/subscription_service.py new file mode 100644 index 0000000..ef9ba32 --- /dev/null +++ b/goodtimes/webhook/subscription_service.py @@ -0,0 +1,78 @@ +from datetime import timedelta +from django.utils import timezone +import datetime +from manage_subscriptions.models import PrincipalSubscription, SubscriptionStatus + + +class SubscriptionService: + def __init__(self): + self._principal_subscription = None + + @property + def principal_subscription(self): + """Get the current principal subscription.""" + return self._principal_subscription + + @principal_subscription.setter + def principal_subscription(self, value): + """Set the current principal subscription.""" + self._principal_subscription = value + + def create_principal_subscription( + self, + principal, + subscription, + stripe_subscription, + order_id, + current_period_start, + current_period_end, + coupon=None, + ): + """Create a principal subscription and return it.""" + start_date, end_date = self._calculate_dates( + current_period_start, current_period_end, subscription.calculate_days() + ) + + PrincipalSubscription.objects.filter(principal=principal, status=SubscriptionStatus.ACTIVE).update(status=SubscriptionStatus.EXPIRED, active=False) + + principal_subscription = PrincipalSubscription.objects.create( + principal=principal, + subscription=subscription, + stripe_subscription_id=stripe_subscription, + is_paid=True, + auto_renew=bool(stripe_subscription), + order_id=order_id, + start_date=start_date, + end_date=end_date, + grace_period_end_date=PrincipalSubscription.generate_grace_period_end_date(end_date), + coupon_code=coupon.coupon_code if coupon else None, + ) + + if coupon: + self._update_coupon(coupon) + + self.principal_subscription = principal_subscription + return principal_subscription + + def _calculate_dates( + self, current_period_start, current_period_end, subscription_days + ): + """Calculate subscription start and end dates.""" + today = timezone.now().date() + start_date = ( + datetime.datetime.fromtimestamp(current_period_start).date() + if current_period_start + else today + ) + end_date = ( + datetime.datetime.fromtimestamp(current_period_end).date() + if current_period_end + else (today + timedelta(days=subscription_days)) + ) + return start_date, end_date + + def _update_coupon(self, coupon): + """Update coupon usage count.""" + coupon.no_of_redeems += 1 + coupon.save() + print("Coupon Saved Successfully!!!") diff --git a/goodtimes/webhook/webhook_service.py b/goodtimes/webhook/webhook_service.py new file mode 100644 index 0000000..c2b9f5a --- /dev/null +++ b/goodtimes/webhook/webhook_service.py @@ -0,0 +1,90 @@ +from decimal import Decimal +from django.conf import settings +import stripe +from django.core.exceptions import ObjectDoesNotExist +from accounts.models import IAmPrincipal +from manage_coupons.models import Coupon +from manage_subscriptions.models import Subscription +import logging + + +logger = logging.getLogger(__name__) + +stripe.api_key = settings.STRIPE_SECRET_KEY + + +class WebhookService: + def __init__(self, webhook_data): + self._webhook_data = webhook_data + self._event_type = webhook_data["type"] + self._charge_data = webhook_data["data"]["object"] + self._metadata = self._fetch_metadata() + + def _fetch_metadata(self): + """Fetch metadata based on the event type.""" + if self._event_type in ["checkout.session.expired", "checkout.session.completed"]: + return self._charge_data.get("metadata", {}) + elif self._event_type == "invoice.payment_succeeded": + subscription_id = self._charge_data.get("subscription") + if subscription_id: + subscription = stripe.Subscription.retrieve(subscription_id) + return subscription.get("metadata", {}) + return {} + + @property + def event_type(self): + return self._event_type + + @property + def charge_data(self): + return self._charge_data + + def _get_object_from_metadata(self, model, id_key): + """Retrieve object from metadata.""" + obj_id = self._metadata.get(id_key) + if obj_id: + try: + return model.objects.get(id=int(obj_id)) + except (ObjectDoesNotExist, ValueError) as e: + logger.error(f"Invalid {model.__name__} ID: {obj_id}") + raise ValueError(f"Invalid {model.__name__} ID: {obj_id}") from e + return None + + def get_event_type(self): + return self.event_type + + def get_principal(self): + """Retrieve principal from metadata.""" + return self._get_object_from_metadata(IAmPrincipal, "principal") + + def get_subscription(self): + """Retrieve subscription from metadata.""" + return self._get_object_from_metadata(Subscription, "subscription_id") + + def get_coupon(self): + """Retrieve coupon from metadata.""" + coupon_code = self._metadata.get("couponCode") + print("get_coupon:coupon_code: ", coupon_code) + if coupon_code: + try: + return Coupon.objects.get(coupon_code=coupon_code) + except Coupon.DoesNotExist: + logger.error(f"Invalid coupon code: {coupon_code}") + raise ValueError(f"Invalid coupon code: {coupon_code}") + return None + + def get_final_amount(self): + """Retrieve Amount after coupon discount from either stripe event or metadata.""" + if self.event_type == "checkout.session.completed": + return ( + Decimal(self._charge_data.get("amount_total", 0)) / 100 + ) + elif self.event_type == "invoice.payment_succeeded": + return ( + Decimal(self._charge_data.get("amount_paid", 0)) / 100 + ) + + # Fallback: Try to get the amount from metadata + return ( + Decimal(self._metadata.get("metadata", {}).get("finalAmount", 0)) / 100 + ) diff --git a/manage_cms/api/serializers.py b/manage_cms/api/serializers.py index b9385f6..1da6dbd 100644 --- a/manage_cms/api/serializers.py +++ b/manage_cms/api/serializers.py @@ -55,6 +55,13 @@ class OrganizationSerializer(serializers.ModelSerializer): "subscription_agreement", "license_agreement_user", "license_agreement_merchant", + "contact_us_email", + "instagram_handle", + "facebook_handle", + "linkedin_handle", + "website_url", + "address", + "contact_us_phone", ] class EducationVideoSerializer(serializers.ModelSerializer): diff --git a/manage_cms/forms.py b/manage_cms/forms.py index 1d79433..7b323f4 100644 --- a/manage_cms/forms.py +++ b/manage_cms/forms.py @@ -22,26 +22,31 @@ class OrganizationForm(forms.ModelForm): fields = [ "title", "contact_us_email", + "contact_us_phone", + "address", + "website_url", "instagram_handle", "facebook_handle", "linkedin_handle", + "twitter_handle", "logo_image", "favicon_image", - "website_url", ] labels = { "title": "Organization Title", "contact_us_email": "Contact Email", + "contact_us_phone": "Contact Phone No", + "Address": "Contact Address", + "website_url": "Website URL", "instagram_handle": "Instagram URL", "facebook_handle": "Facebook URL", "linkedin_handle": "LinkedIn URL", + "Twitter_handle": "Twitter URL", "logo_image": "Organization Logo", "favicon_image": "Favicon", - "website_url": "Website URL", } - class NewsAndArticleCategoryForm(forms.ModelForm): class Meta: model = NewsAndArticlesCategory diff --git a/manage_cms/migrations/0002_organization_address_organization_contact_us_phone.py b/manage_cms/migrations/0002_organization_address_organization_contact_us_phone.py new file mode 100644 index 0000000..d30c25f --- /dev/null +++ b/manage_cms/migrations/0002_organization_address_organization_contact_us_phone.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.2 on 2024-08-16 08:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_cms', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='address', + field=models.TextField(default='this is address'), + preserve_default=False, + ), + migrations.AddField( + model_name='organization', + name='contact_us_phone', + field=models.CharField(default='+911212121212', max_length=16), + preserve_default=False, + ), + ] diff --git a/manage_cms/migrations/0003_organization_twitter_handle_and_more.py b/manage_cms/migrations/0003_organization_twitter_handle_and_more.py new file mode 100644 index 0000000..fbbb32e --- /dev/null +++ b/manage_cms/migrations/0003_organization_twitter_handle_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.2 on 2024-08-16 09:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_cms', '0002_organization_address_organization_contact_us_phone'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='twitter_handle', + field=models.URLField(blank=True, null=True), + ), + migrations.AlterField( + model_name='organization', + name='address', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='organization', + name='contact_us_phone', + field=models.CharField(blank=True, max_length=16, null=True), + ), + ] diff --git a/manage_cms/models.py b/manage_cms/models.py index 861aa8f..65474d3 100644 --- a/manage_cms/models.py +++ b/manage_cms/models.py @@ -89,6 +89,7 @@ class Organization(BaseModel): instagram_handle = models.URLField(blank=True, null=True) facebook_handle = models.URLField(blank=True, null=True) linkedin_handle = models.URLField(blank=True, null=True) + twitter_handle = models.URLField(blank=True, null=True) logo_image = models.ImageField(blank=True, null=True, upload_to="organization/logo") favicon_image = models.ImageField( blank=True, null=True, upload_to="organization/favicon" @@ -104,6 +105,8 @@ class Organization(BaseModel): subscription_agreement = QuillField() license_agreement_user = QuillField() license_agreement_merchant = QuillField() + address = models.TextField(blank=True, null=True) + contact_us_phone = models.CharField(max_length=16, blank=True, null=True) class Meta: db_table = "organization" diff --git a/manage_communications/utils.py b/manage_communications/utils.py index 1c7f74a..a9edfd2 100644 --- a/manage_communications/utils.py +++ b/manage_communications/utils.py @@ -2,7 +2,8 @@ import logging from threading import Thread # Configure logging at the beginning of your application -logging.basicConfig(level=logging.INFO, filename='app.log', filemode='a', format='%(name)s - %(levelname)s - %(message)s') +# logging.basicConfig(level=logging.INFO, filename='app.log', filemode='a', format='%(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) def send_email_async(email_service): try: @@ -11,6 +12,6 @@ def send_email_async(email_service): except Exception as e: # Log the exception print(f"Failed to send email: {e}") - logging.error(f"Failed to send email: {e}") + logger.error(f"Failed to send email: {e}") # Optionally, you could use other means to notify you of the failure, # such as sending an alert to an admin email, or using a monitoring service. \ No newline at end of file diff --git a/manage_communications/views.py b/manage_communications/views.py index 6cdaeba..a1a2108 100644 --- a/manage_communications/views.py +++ b/manage_communications/views.py @@ -56,7 +56,7 @@ class ContactUsReplyView(LoginRequiredMixin, generic.View): to=[ email, ], - from_email=settings.EMAIL_HOST_USER, + from_email=settings.DEFAULT_FROM_EMAIL, ) print("email_service: ", email_service) email_service.load_template( diff --git a/manage_coupons/__init__.py b/manage_coupons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_coupons/admin.py b/manage_coupons/admin.py new file mode 100644 index 0000000..e195db7 --- /dev/null +++ b/manage_coupons/admin.py @@ -0,0 +1,40 @@ +from django.contrib import admin +from .models import Coupon + + +class CouponAdmin(admin.ModelAdmin): + list_display = ( + "id", + "title", + "coupon_id", + "coupon_code", + "discount_amount", + "discount_percentage", + "valid_from", + "valid_to", + "max_redeems", + "no_of_redeems", + "is_active", + ) + search_fields = ("title", "coupon_code") + list_filter = ("valid_from", "valid_to", "max_redeems") + readonly_fields = ("no_of_redeems",) + + fieldsets = ( + ( + None, + {"fields": ("title", "coupon_code", "coupon_id", "description", "image")}, + ), + ( + "Discount Information", + {"fields": ("discount_amount", "discount_percentage")}, + ), + ("Validity", {"fields": ("valid_from", "valid_to")}), + ("Redemption", {"fields": ("max_redeems", "no_of_redeems")}), + ) + + def is_active(self, obj): + return obj.is_valid() + + +admin.site.register(Coupon, CouponAdmin) diff --git a/manage_coupons/api/serializers.py b/manage_coupons/api/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_coupons/api/urls.py b/manage_coupons/api/urls.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_coupons/api/views.py b/manage_coupons/api/views.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_coupons/apps.py b/manage_coupons/apps.py new file mode 100644 index 0000000..a1391cc --- /dev/null +++ b/manage_coupons/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ManageCouponsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "manage_coupons" diff --git a/manage_coupons/forms.py b/manage_coupons/forms.py new file mode 100644 index 0000000..22874e5 --- /dev/null +++ b/manage_coupons/forms.py @@ -0,0 +1,24 @@ +from django import forms +from django.core.exceptions import ValidationError +from manage_coupons.models import Coupon + + +class CouponForm(forms.ModelForm): + class Meta: + model = Coupon + fields = [ + "title", + "description", + # "image", + "discount_amount", + "discount_percentage", + "valid_from", + "valid_to", + "max_redeems", + ] + widgets = { + "valid_from": forms.DateTimeInput(attrs={"type": "datetime-local"}), + "valid_to": forms.DateTimeInput(attrs={"type": "datetime-local"}), + # "discount_amount": forms.NumberInput(attrs={'step': '0.01'}), + # "discount_percentage": forms.NumberInput(attrs={'step': '0.01'}), + } diff --git a/manage_coupons/migrations/0001_initial.py b/manage_coupons/migrations/0001_initial.py new file mode 100644 index 0000000..728c884 --- /dev/null +++ b/manage_coupons/migrations/0001_initial.py @@ -0,0 +1,81 @@ +# Generated by Django 5.0.2 on 2024-07-22 12:20 + +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="Coupon", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("coupon_code", models.CharField(max_length=50, unique=True)), + ("no_of_redeems", models.IntegerField(default=0)), + ("description", models.TextField(blank=True, null=True)), + ( + "image", + models.ImageField(blank=True, null=True, upload_to="coupon_img"), + ), + ( + "discount_amount", + models.DecimalField( + blank=True, decimal_places=2, max_digits=10, null=True + ), + ), + ( + "discount_percentage", + models.DecimalField( + blank=True, decimal_places=2, max_digits=5, null=True + ), + ), + ("valid_from", models.DateTimeField()), + ("valid_to", models.DateTimeField()), + ("max_redeems", models.IntegerField(default=0)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "coupon", + }, + ), + ] diff --git a/manage_coupons/migrations/0002_coupon_coupon_id.py b/manage_coupons/migrations/0002_coupon_coupon_id.py new file mode 100644 index 0000000..999883c --- /dev/null +++ b/manage_coupons/migrations/0002_coupon_coupon_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-07-31 07:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("manage_coupons", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="coupon", + name="coupon_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/manage_coupons/migrations/0003_alter_coupon_discount_amount_and_more.py b/manage_coupons/migrations/0003_alter_coupon_discount_amount_and_more.py new file mode 100644 index 0000000..c18f000 --- /dev/null +++ b/manage_coupons/migrations/0003_alter_coupon_discount_amount_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.2 on 2024-08-21 10:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_coupons', '0002_coupon_coupon_id'), + ] + + operations = [ + migrations.AlterField( + model_name='coupon', + name='discount_amount', + field=models.DecimalField(blank=True, decimal_places=2, help_text='Representing the amount to subtract from an invoice total (required if discount_percentage is not passed)', max_digits=10, null=True), + ), + migrations.AlterField( + model_name='coupon', + name='discount_percentage', + field=models.DecimalField(blank=True, decimal_places=2, help_text='A positive float larger than 0, and smaller or equal to 100, that represents the discount the coupon will apply (required if discount_amount is not passed).', max_digits=5, null=True), + ), + migrations.AlterField( + model_name='coupon', + name='max_redeems', + field=models.IntegerField(default=1), + ), + migrations.AlterField( + model_name='coupon', + name='valid_to', + field=models.DateTimeField(help_text='Datetime for the last redeemable date. After this, the coupon is invalid for new customers.'), + ), + ] diff --git a/manage_coupons/migrations/__init__.py b/manage_coupons/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_coupons/models.py b/manage_coupons/models.py new file mode 100644 index 0000000..08cb2b7 --- /dev/null +++ b/manage_coupons/models.py @@ -0,0 +1,104 @@ +from decimal import Decimal +from django.db import models +from django.utils import timezone +from accounts.models import BaseModel, IAmPrincipalType +from django.core.exceptions import ValidationError + + +class Coupon(BaseModel): + title = models.CharField(max_length=255) + coupon_code = models.CharField(max_length=50, unique=True) + coupon_id = models.CharField(max_length=255, blank=True, null=True) + no_of_redeems = models.IntegerField(default=0) + description = models.TextField(null=True, blank=True) + image = models.ImageField(upload_to="coupon_img", null=True, blank=True) + discount_amount = models.DecimalField( + max_digits=10, decimal_places=2, null=True, blank=True, help_text="Representing the amount to subtract from an invoice total (required if discount_percentage is not passed)" + ) + discount_percentage = models.DecimalField( + max_digits=5, decimal_places=2, null=True, blank=True, help_text="A positive float larger than 0, and smaller or equal to 100, that represents the discount the coupon will apply (required if discount_amount is not passed)." + ) + valid_from = models.DateTimeField() + valid_to = models.DateTimeField(help_text="Datetime for the last redeemable date. After this, the coupon is invalid for new customers.") + max_redeems = models.IntegerField(default=1) + + class Meta: + db_table = "coupon" + + def __str__(self): + return self.coupon_code + + def clean(self): + """ + Validate the Coupon instance. Ensure that the `max_redeems` is greater than 0, + that either `discount_amount` or `discount_percentage` is set, and that + `valid_from` is earlier than `valid_to`. + """ + if self.max_redeems < 1: + raise ValidationError({"max_redeems": "Redeems must be more than 1."}) + + # Ensure discount_amount is non-negative + if self.discount_amount is not None and self.discount_amount < 1: + raise ValidationError( + {"discount_amount": "Discount amount must be more than 1."} + ) + + # Ensure discount_percentage is non-negative + if self.discount_percentage is not None and self.discount_percentage < 1: + raise ValidationError( + {"discount_percentage": "Discount percentage must be more than 1."} + ) + + if self.discount_amount and self.discount_percentage: + raise ValidationError( + "You can only set either a discount amount or a discount percentage, not both." + ) + + if not self.discount_amount and not self.discount_percentage: + raise ValidationError( + "You must set either a discount amount or a discount percentage." + ) + + if self.valid_from and self.valid_to and self.valid_from >= self.valid_to: + raise ValidationError( + "The valid_from date must be earlier than the valid_to date." + ) + + def save(self, *args, **kwargs): + from goodtimes.services import StripeService + if not self.delete: + self.clean() # Call clean before saving to ensure validation + + if not self.pk and not self.coupon_id: + amount_off = int(self.discount_amount * Decimal(100)) if self.discount_amount else None + percent_off = float(self.discount_percentage) if self.discount_percentage else None + + result = StripeService.create_coupon( + amount_off=amount_off, + percent_off=percent_off, + duration="once", + name=self.title, + redeem_by=int(self.valid_to.timestamp()), + max_redemptions=self.max_redeems, + currency='gbp', + metadata={"local_id": self.id} + ) + + if not result["success"]: + raise ValueError(f"Failed to create Stripe coupon: {result['message']}") + + self.coupon_code = result['data'].id + self.coupon_id = result["data"].id + + super().save(*args, **kwargs) + + # If max_redeems is 0, it means that we are allowing unlimited redeems + + # def is_valid(self): + # now = timezone.now() + # return ( + # self.active + # and not self.deleted + # and self.valid_from <= now <= self.valid_to + # and (self.max_redeems == 0 or self.no_of_redeems < self.max_redeems) + # ) diff --git a/manage_coupons/tests.py b/manage_coupons/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/manage_coupons/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/manage_coupons/urls.py b/manage_coupons/urls.py new file mode 100644 index 0000000..2d53400 --- /dev/null +++ b/manage_coupons/urls.py @@ -0,0 +1,23 @@ +from django.urls import path +from . import views + +app_name = "manage_coupons" + +urlpatterns = [ + path("coupon/list/", views.CouponView.as_view(), name="coupon_list"), + path( + "coupon/add/", + views.CouponCreateOrUpdateView.as_view(), + name="coupon_add", + ), + # path( + # "coupon/edit//", + # views.CouponCreateOrUpdateView.as_view(), + # name="coupon_edit", + # ), + path( + "coupon/delete//", + views.CouponDeleteView.as_view(), + name="coupon_delete", + ), +] diff --git a/manage_coupons/utils.py b/manage_coupons/utils.py new file mode 100644 index 0000000..2cacf99 --- /dev/null +++ b/manage_coupons/utils.py @@ -0,0 +1,47 @@ +import stripe +from decimal import Decimal + + +def handle_stripe_coupon(coupon_instance, stripe_secret_key): + """ + Handles the creation or updating of a Stripe coupon. + Returns True if successful, otherwise returns False. + """ + try: + stripe.api_key = stripe_secret_key + + # Prepare coupon data without setting the ID + coupon_data = { + "name": coupon_instance.title, + "metadata": { + "local_id": coupon_instance.id, + }, + "redeem_by": int(coupon_instance.valid_to.timestamp()), + "max_redemptions": ( + coupon_instance.max_redeems if coupon_instance.max_redeems > 0 else None + ), + "duration": "once", + } + + if coupon_instance.discount_amount: + coupon_data["amount_off"] = int( + coupon_instance.discount_amount * Decimal(100) + ) # Amount in cents/fils + coupon_data["currency"] = "gbp" + elif coupon_instance.discount_percentage: + coupon_data["percent_off"] = float(coupon_instance.discount_percentage) + + # Creating a new Stripe coupon + stripe_coupon = stripe.Coupon.create(**coupon_data) + # Using the Stripe-generated ID for coupon_code and coupon_id + coupon_instance.coupon_code = stripe_coupon.id + coupon_instance.coupon_id = stripe_coupon.id + + # Saving the coupon instance after successful Stripe operation + coupon_instance.save() + return True, "Coupon successfully created." + + except Exception as e: + error_message = f"Error creating Stripe coupon: {e}" + print(error_message) + return False, error_message diff --git a/manage_coupons/views.py b/manage_coupons/views.py new file mode 100644 index 0000000..754951e --- /dev/null +++ b/manage_coupons/views.py @@ -0,0 +1,129 @@ +from django.conf import settings +from django.shortcuts import get_object_or_404, render, redirect +from django.views import generic +from django.contrib.auth.mixins import LoginRequiredMixin +import stripe +from accounts import resource_action +from django.urls import reverse_lazy +from django.contrib import messages +from goodtimes import constants +from manage_coupons.forms import CouponForm +from manage_coupons.models import Coupon +from manage_coupons.utils import handle_stripe_coupon + +# Create your views here. + + +class CouponView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_COUPONS + resource = resource_action.RESOURCE_MANAGE_COUPONS + action = resource_action.ACTION_READ + model = Coupon + template_name = "manage_coupons/coupon_list.html" + context_object_name = "coupon_obj" + + def get_queryset(self): + queryset = super().get_queryset().filter(deleted=False, active=True) + return queryset.order_by("-created_on") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class CouponCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_COUPONS + resource = resource_action.RESOURCE_MANAGE_COUPONS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE + + template_name = "manage_coupons/coupon_add.html" + model = Coupon + form_class = CouponForm + success_url = reverse_lazy("manage_coupons:coupon_list") + error_message = "An error occurred while saving the data." + + # Determine the success message dynamically based on whether it's an update or create + def get_success_message(self): + self.success_message = ( + constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED + ) + return self.success_message + + # Get the object (if exists) based on URL parameter 'pk' + def get_object(self): + pk = self.kwargs.get("pk") + return get_object_or_404(self.model, pk=pk) if pk else None + + # Add page_name and operation to the context + def get_context_data(self, **kwargs): + context = { + "page_name": self.page_name, + "operation": "Add" if not self.object else "Edit", + } + context.update(kwargs) # Include any additional context data passed to the view + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(instance=self.object) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # If an object is found, change action to ACTION_UPDATE + if self.object is not None: + self.action = resource_action.ACTION_UPDATE + + form = self.form_class(request.POST, request.FILES, instance=self.object) + if not form.is_valid(): + print(form.errors) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + + form.save() + messages.success(request, self.get_success_message) + return redirect(self.success_url) + + +class CouponDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_COUPONS + resource = resource_action.RESOURCE_MANAGE_COUPONS + action = resource_action.ACTION_DELETE + model = Coupon + success_url = reverse_lazy("manage_coupons:coupon_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND + + def get(self, request, pk): + try: + type_obj = self.model.objects.get(id=pk) + + if type_obj.coupon_id: + stripe.api_key = settings.STRIPE_SECRET_KEY + try: + stripe.Coupon.delete(type_obj.coupon_id) + except stripe.error.StripeError as e: + # Handle Stripe errors + error_message = f"Stripe error: {e.user_message or e}" + messages.error(request, error_message) + return redirect(self.success_url) + + type_obj.deleted = True + type_obj.active = False + type_obj.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.warning(request, self.error_message) + + return redirect(self.success_url) diff --git a/manage_events/admin.py b/manage_events/admin.py index ac72d3d..fab8c59 100644 --- a/manage_events/admin.py +++ b/manage_events/admin.py @@ -1,5 +1,16 @@ from django.contrib import admin -from .models import EventCategory, Venue, EventMaster, Event, EventPrincipalInteraction +from .models import ( + EventCategory, + EventShare, + EventView, + Favorites, + FreeUsageFeatureLimit, + Venue, + EventMaster, + Event, + EventPrincipalInteraction, + AgeGroups +) # Register your models here. @@ -78,7 +89,7 @@ class EventAdmin(admin.ModelAdmin): }, ), ) - filter_horizontal = () # Use this if there are many-to-many fields + filter_horizontal = () # if there are many-to-many fields raw_id_fields = ("venue", "category", "event_master") @@ -88,11 +99,37 @@ class EventPrincipalInteractionAdmin(admin.ModelAdmin): search_fields = ( "principal__name", "event__title", - ) # Adjust these field lookups according to your models. + ) +class EventViewAdmin(admin.ModelAdmin): + list_display = ("id", "event", "principal", "view_date", "location") + search_fields = ("event__title", "principal__email", "location") + list_filter = ("id", "view_date", "location", "event__title", "principal__email") + ordering = ("-view_date",) + readonly_fields = ("id",) + + +class EventShareAdmin(admin.ModelAdmin): + list_display = ("id", "event", "principal", "created_on") + search_fields = ("event__title", "principal__username") + list_filter = ("id", "event", "principal", "created_on") + + +class FavoritesAdmin(admin.ModelAdmin): + list_display = ("id", "principal", "event") + search_fields = ("principal__username", "event__title") + list_filter = ("principal", "event") + ordering = ("id",) + + +admin.site.register(Favorites, FavoritesAdmin) +admin.site.register(EventShare, EventShareAdmin) +admin.site.register(EventView, EventViewAdmin) admin.site.register(EventPrincipalInteraction, EventPrincipalInteractionAdmin) admin.site.register(Event, EventAdmin) admin.site.register(EventCategory, EventCategoryAdmin) admin.site.register(Venue, VenueAdmin) admin.site.register(EventMaster, EventMasterAdmin) +admin.site.register(AgeGroups) +admin.site.register(FreeUsageFeatureLimit) diff --git a/manage_events/api/filters.py b/manage_events/api/filters.py new file mode 100644 index 0000000..856ccbe --- /dev/null +++ b/manage_events/api/filters.py @@ -0,0 +1,52 @@ +from django_filters import rest_framework as filters +from django.db.models import Count, Q +from ..models import Event, EventInteractionType + + +class EventFilter(filters.FilterSet): + """ + FilterSet for Event model. + """ + title = filters.CharFilter(method="filter_title") + location = filters.CharFilter(field_name="venue__address", lookup_expr="icontains") + category = filters.CharFilter(method="filter_category") + start_date = filters.DateFilter(field_name="start_date", lookup_expr="gte") + # end_date = filters.DateFilter(field_name="end_date", lookup_expr="lte") + price_from = filters.NumberFilter(field_name="entry_fee", lookup_expr="gte") + price_to = filters.NumberFilter(field_name="entry_fee", lookup_expr="lte") + age_group = filters.CharFilter(method="filter_age_group") + + class Meta: + model = Event + fields = [ + 'title', + 'location', + 'category', + 'start_date', + # 'end_date', + 'price_from', + 'price_to', + 'age_group', + ] + + def filter_title(self, queryset, name, value): + if value: + return queryset.filter( + Q(title__icontains=value) | Q(tags__name__icontains=value) + ).distinct() + return queryset + + def filter_category(self, queryset, name, value): + category = value.split(',') + return queryset.filter(category__title__in=category) + + def filter_age_group(self, queryset, name, value): + age_group = value.split(',') + return queryset.filter(age_group__in=age_group) + + + # def filter_queryset(self, queryset): + # queryset = super().filter_queryset(queryset) + # if 'price_from' in self.data or 'price_to' in self.data: + # queryset = queryset.order_by('entry_fee') + # return queryset \ No newline at end of file diff --git a/manage_events/api/serializers.py b/manage_events/api/serializers.py index 2a36c82..f6c15ea 100644 --- a/manage_events/api/serializers.py +++ b/manage_events/api/serializers.py @@ -6,6 +6,7 @@ from taggit.models import Tag from manage_events.utils import get_location_info from accounts.api.serializers import ProfileSerializer from manage_events.models import ( + AgeGroups, EventMaster, Event, EventCategory, @@ -13,11 +14,18 @@ from manage_events.models import ( EventPrincipalInteraction, EventReview, Favorites, + FreeUsageFeatureLimit, Venue, PrincipalPreference, ) +class FreeUsageFeatureLimitSerializer(serializers.ModelSerializer): + class Meta: + model = FreeUsageFeatureLimit + fields = ['id', 'category_limit'] + + class EventImageSerializer(serializers.ModelSerializer): class Meta: model = EventImage @@ -38,18 +46,39 @@ class VenueSerializer(serializers.ModelSerializer): fields = "__all__" read_only_fields = ("created_by",) +class VenueShortSerializer(serializers.ModelSerializer): + class Meta: + model = Venue + fields = ["id", "title"] + class EventCategorySerializer(serializers.ModelSerializer): class Meta: model = EventCategory fields = ["id", "title", "image", "description", "video_url"] + def get_image_url(self, obj, field_name, request): + image_field = getattr(obj, field_name) + if image_field: + return request.build_absolute_uri(image_field.url) + return "" + + def to_representation(self, instance): + data = super().to_representation(instance) + request = self.context.get("request") + data["image"] = self.get_image_url(instance, "image", request) + return data + +class AgeGroupsSerializer(serializers.ModelSerializer): + class Meta: + model = AgeGroups + fields = ['id', 'name'] class EventListSerializer(serializers.ModelSerializer): category = EventCategorySerializer(read_only=True) - venue = VenueSerializer(read_only=True) - draft = serializers.BooleanField(read_only=True) - tags = TagSerializer(many=True, read_only=True) + # venue = VenueSerializer(read_only=True) + # draft = serializers.BooleanField(read_only=True) + # tags = TagSerializer(many=True, read_only=True) class Meta: model = Event @@ -62,22 +91,29 @@ class EventListSerializer(serializers.ModelSerializer): "from_time", "to_time", "category", - "venue", - "venue_capacity", + # "venue", + # "venue_capacity", "image", # "video_url", - "entry_type", + # "entry_type", "entry_fee", "key_guest", "age_group", + "link", # "images", # "is_favorited", # "reviews", - "tags", + # "tags", # "principal_interaction", - "draft", + # "draft", ] + # def to_representation(self, instance): + # """Customize the representation of the instance.""" + # representation = super().to_representation(instance) + # representation['key_guest'] = instance.get_key_guests() # Return as a list + # return representation + class EventDetailSerializer(serializers.ModelSerializer): tags = TagSerializer(many=True, read_only=True) @@ -107,6 +143,9 @@ class EventDetailSerializer(serializers.ModelSerializer): "entry_type", "entry_fee", "key_guest", + "coupon_code", + "coupon_description", + "link", "age_group", "images", "is_favorited", @@ -147,6 +186,12 @@ class EventDetailSerializer(serializers.ModelSerializer): "status_display": interaction.get_status_display(), } return None + + # def to_representation(self, instance): + # """Customize the representation of the instance.""" + # representation = super().to_representation(instance) + # representation['key_guest'] = instance.get_key_guests() # Return as a list + # return representation class CreateEventSerializer(serializers.ModelSerializer): @@ -177,11 +222,21 @@ class CreateEventSerializer(serializers.ModelSerializer): "draft", "venue", "tags", + "coupon_code", + "coupon_description", + "link" ] + def validate_key_guest(self, value): + if value and not isinstance(value, str): + raise serializers.ValidationError("key_guest must be a string") + return value + def create(self, validated_data): tags = validated_data.pop("tags", None) images_data = validated_data.pop("images", None) + key_guest = validated_data.pop("key_guest", None) + event = Event.objects.create(**validated_data) if tags: @@ -191,11 +246,15 @@ class CreateEventSerializer(serializers.ModelSerializer): for image_data in images_data: EventImage.objects.create(event=event, image=image_data) + if key_guest: + event.set_key_guests(key_guest) + event.save() return event def update(self, instance, validated_data): tags = validated_data.pop("tags", None) images_data = validated_data.pop("images", None) + key_guest = validated_data.pop("key_guest", None) # Update fields if there is any change. if tags is not None: @@ -204,11 +263,13 @@ class CreateEventSerializer(serializers.ModelSerializer): instance.tags.add(*tags) if images_data is not None: - # Assuming you want to add new images without deleting the old ones - # If you want to replace them, you should delete the old images first + EventImage.objects.filter(event=instance).delete() for image_data in images_data: EventImage.objects.create(event=instance, image=image_data) + if key_guest is not None: + instance.set_key_guests(key_guest) + # Update other fields for attr, value in validated_data.items(): setattr(instance, attr, value) @@ -216,6 +277,12 @@ class CreateEventSerializer(serializers.ModelSerializer): return instance + # def to_representation(self, instance): + # """Customize the representation of the instance.""" + # representation = super().to_representation(instance) + # representation['key_guest'] = instance.get_key_guests() # Return as a list + # return representation + class CreateVenueSerializer(serializers.ModelSerializer): class Meta: @@ -236,32 +303,6 @@ class IAmPrincipalLocationSerializer(serializers.ModelSerializer): model = IAmPrincipalLocation fields = ["latitude", "longitude"] - def create(self, validated_data): - principal = self.context["request"].user - latitude = validated_data.get("latitude") - longitude = validated_data.get("longitude") - location = get_location_info(latitude=latitude, longitude=longitude) - print("location: ", location) - city = location.get("city") - state = location.get("state") - country = location.get("country") - country_code = location.get("country_code") - - if hasattr(principal, "city"): - principal.city = city or state # Use state as city if city is not found - if hasattr(principal, "state"): - principal.state = state - if hasattr(principal, "country"): - principal.country = country - if hasattr(principal, "address_line1"): - principal.address_line1 = country_code - - # save the principal object after making changes - principal.save() - return IAmPrincipalLocation.objects.create( - principal=principal, **validated_data - ) - class PrincipalPreferenceSerializer(serializers.ModelSerializer): preferred_categories = serializers.PrimaryKeyRelatedField( diff --git a/manage_events/api/urls.py b/manage_events/api/urls.py index 4e632f9..07fc2b4 100644 --- a/manage_events/api/urls.py +++ b/manage_events/api/urls.py @@ -4,13 +4,14 @@ from . import views app_name = "manage_events_api" urlpatterns = [ + path('free/feature-limit/', views.FreeUsageFeatureLimitView.as_view(), name='feature-limit'), path( "add-event/", views.CreateEventApi.as_view(), name="add_event", ), path("edit-event//", views.EventEditAPIView.as_view(), name="event-edit"), - path("get-events/", views.EventsAPIView.as_view(), name="events"), + path( "event//", views.EventDetailAPIView.as_view(), @@ -56,6 +57,11 @@ urlpatterns = [ views.PrincipalPreferenceDetailView.as_view(), name="principal-preferences", ), + path( + "preferences/", + views.EventPreferencesView.as_view(), + name="preferences", + ), # Principal Location path( "add-location/", @@ -111,4 +117,32 @@ urlpatterns = [ name="principal-events", ), path("tags/", views.TagListView.as_view(), name="tag-list"), + # For counting event views + path( + "event//view/", + views.CaptureEventViewAPIView.as_view(), + name="capture_event_view", + ), + # For counting event shares + path( + "event//share/", + views.EventShareView.as_view(), + name="capture_event_share", + ), + + path( + "age-groups/", views.AgeGroupListView.as_view(), + name="age_group_list" + ), + + path("get-events/calendar/", views.EventsCalenderAPIView.as_view(), name="events-calendar"), + + # event list with filter + path( + "events/", + views.EventListView.as_view(), + name="event_filter", + ), + + path("post-to-social-media//", views.SocialMediaPostView.as_view(), name="social_media_post") ] diff --git a/manage_events/api/views.py b/manage_events/api/views.py index 6c632d5..99228c2 100644 --- a/manage_events/api/views.py +++ b/manage_events/api/views.py @@ -1,19 +1,25 @@ +import datetime from django.shortcuts import get_object_or_404 from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend +import googlemaps from rest_framework import status, generics, mixins from rest_framework.views import APIView from django.conf import settings +from accounts import resource_action from accounts.models import IAmPrincipalLocation from goodtimes import constants -from django.db.models import Q +from django.db.models import Q, Count from taggit.models import Tag from django.utils.dateparse import parse_date from goodtimes import services +from goodtimes.services import FacebookAPI, FacebookPoster, GoogleMapsservice, InstagramAPI, InstagramPoster, TwitterAPI, TwitterPoster from goodtimes.utils import ApiResponse, CapacityError from rest_framework.permissions import IsAuthenticated from rest_framework_simplejwt.authentication import JWTAuthentication from manage_cms.api.serializers import TagSerializer from manage_events.api.serializers import ( + AgeGroupsSerializer, EventDateRangeSerializer, EventMasterSearchSerializer, EventMasterSerializer, @@ -22,26 +28,47 @@ from manage_events.api.serializers import ( EventCategorySerializer, EventDetailSerializer, EventReviewSerializer, + FreeUsageFeatureLimitSerializer, IAmPrincipalLocationSerializer, PrincipalPreferenceSerializer, VenueSerializer, EventListSerializer, ) from manage_events.models import ( + AgeGroups, EventInteractionType, EventMaster, Event, EventCategory, EventPrincipalInteraction, EventReview, + EventShare, + EventView, Favorites, + FreeUsageFeatureLimit, PrincipalPreference, Venue, ) import requests -from manage_events.utils import haversine_one +from manage_events.utils import haversine_one, update_principal_location +from manage_subscriptions.models import PrincipalSubscription +from .filters import EventFilter +class FreeUsageFeatureLimitView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + model = FreeUsageFeatureLimit + serializer_class = FreeUsageFeatureLimitSerializer + + def get(self, request): + obj = self.model.objects.first() + serializer = self.serializer_class(obj) + return ApiResponse.success( + status=status.HTTP_200_OK, + message=constants.SUCCESS, + data=serializer.data, + ) class CreateEventApi(APIView): authentication_classes = [JWTAuthentication] @@ -52,7 +79,7 @@ class CreateEventApi(APIView): serializer = CreateEventSerializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save(created_by=self.request.user) + serializer.save(created_by=self.request.user, principal=self.request.user) # Add additional logic for handling other relationships (e.g., Venue) return ApiResponse.success( @@ -110,10 +137,20 @@ class CreateVenueApi(APIView): permission_classes = [IsAuthenticated] def post(self, request): - serializer = VenueSerializer(data=request.data, context={"request": request}) + + data = request.data.copy() + print("prindata is ", data) + + # Convert latitude and longitude to float and round to 8 decimal places + data["latitude"] = round(float(data["latitude"]), 8) + data["longitude"] = round(float(data["longitude"]), 8) + + serializer = VenueSerializer(data=data, context={"request": request}) serializer.is_valid(raise_exception=True) - serializer.save(created_by=self.request.user, active=True) + serializer.save( + created_by=self.request.user, principal=self.request.user, active=True + ) # Add additional logic for handling other relationships (e.g., Venue) return ApiResponse.success( @@ -123,6 +160,20 @@ class CreateVenueApi(APIView): ) +# # Prepare the email +# subject = f"Your Event Report for {start_date.month} {start_date.year}." +# body = f"Please find attached the event report for {start_date.month} {start_date.month}." +# email_service = EmailService( +# subject="Good Times - Report", +# to=[user.email], +# from_email=settings.DEFAULT_FROM_EMAIL, +# ) +# email_service.attach(filename, buffer.getvalue(), "application/pdf") + +# # Send the email +# email_service.send() + + class VenueDeleteAPIView(APIView): authentication_classes = [JWTAuthentication] permission_classes = [IsAuthenticated] @@ -149,67 +200,6 @@ class VenueDeleteAPIView(APIView): ) -class EventsAPIView(APIView): - authentication_classes = [JWTAuthentication] - permission_classes = [IsAuthenticated] - - def get(self, request, *args, **kwargs): - filter = request.query_params.get("filter", None) - query = request.query_params.get("query", None) - category_id = request.query_params.get("category_id", None) - params = [ - "expensive", - "cheap", - "preference", - "today", - "tomorrow", - "category", - "key_guest", - "tags", - ] - if filter not in params: - return ApiResponse.error( - status=status.HTTP_400_BAD_REQUEST, - message=constants.FAILURE, - errors="No filter found", - ) - - try: - if filter == "today": - events = services.EventFilterService.filter_events_for_today() - elif filter == "tomorrow": - events = services.EventFilterService.filter_events_for_tomorrow() - elif filter == "key_guest": - events = services.EventFilterService.filter_events_by_search( - search_query=query - ) - elif filter == "category" and category_id is not None: - events = services.EventFilterService.filter_events_by_category( - int(category_id) - ) - else: - events = services.EventFilterService.filter_events( - filter_type=filter, principal=request.user - ) - serializer = EventDetailSerializer( - events, context={"request": request}, many=True - ) - # serializer = EventListSerializer( - # events, context={"request": request}, many=True - # ) - return ApiResponse.success( - status=status.HTTP_200_OK, - message=constants.SUCCESS, - data=serializer.data, - ) - except Exception as e: - return ApiResponse.error( - status=status.HTTP_400_BAD_REQUEST, - message=constants.FAILURE, - errors=str(e), - ) - - class MyEventsAPIView(APIView): authentication_classes = [JWTAuthentication] permission_classes = [IsAuthenticated] @@ -248,6 +238,9 @@ class MyEventsAPIView(APIView): serializer = EventDetailSerializer( events, context={"request": request}, many=True ) + # serializer = EventListSerializer( + # events, context={"request": request}, many=True + # ) return ApiResponse.success( status=status.HTTP_200_OK, message=constants.SUCCESS, @@ -445,22 +438,58 @@ class IAmPrincipalLocationAPIView(APIView): ) def post(self, request, *args, **kwargs): - serializer = IAmPrincipalLocationSerializer( - data=request.data, context={"request": request} - ) - print("serializer: ", serializer) - if serializer.is_valid(): - serializer.save() + data = request.data.copy() + + # Convert latitude and longitude to float and round to 8 decimal places + latitude = round(float(data["latitude"]), 15) + longitude = round(float(data["longitude"]), 15) + + try: + principal = request.user + location = IAmPrincipalLocation.objects.get(principal=principal) + + # Update existing location + location.latitude = latitude + location.longitude = longitude + location.save() + + # Update principal fields using the utility function + update_principal_location(principal, latitude, longitude) + + serializer = IAmPrincipalLocationSerializer( + location, context={"request": request} + ) return ApiResponse.success( status=status.HTTP_200_OK, message=constants.SUCCESS, data=serializer.data, ) - return ApiResponse.error( - status=status.HTTP_400_BAD_REQUEST, - message=constants.FAILURE, - errors=serializer.errors, - ) + + except IAmPrincipalLocation.DoesNotExist: + # Create a new location object + location = IAmPrincipalLocation.objects.create( + principal=principal, latitude=latitude, longitude=longitude + ) + + # Update principal fields using the utility function + update_principal_location(principal, latitude, longitude) + + serializer = IAmPrincipalLocationSerializer( + location, context={"request": request} + ) + return ApiResponse.success( + status=status.HTTP_201_CREATED, + message=constants.SUCCESS, + data=serializer.data, + ) + + except Exception as e: + print(f"Error occurred while saving location: {e}") + return ApiResponse.error( + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + message=constants.FAILURE, + errors=str(e), + ) class PrincipalPreferenceView(APIView): @@ -468,6 +497,27 @@ class PrincipalPreferenceView(APIView): permission_classes = [IsAuthenticated] def post(self, request, *args, **kwargs): + + principal = request.user + # Check if the principal has a subscription + if not PrincipalSubscription.has_principal_subscription(principal): + # Get the preferred categories from the request data + preferred_categories = request.data.get("preferred_categories", []) + + # Get the category limit for free usage + category_limit = FreeUsageFeatureLimit.get_category_limit() + + # Check if the principal is an event user and has exceeded the category limit + if principal.principal_type.name == resource_action.PRINCIPAL_TYPE_EVENT_USER and len(preferred_categories) > category_limit: + # Create an error message indicating that a paid subscription is required + error_message = f"Upgrade to paid subscription to select more than {category_limit} categories." + + return ApiResponse.error( + status=status.HTTP_400_BAD_REQUEST, + message=error_message, + errors=error_message, + ) + serializer = PrincipalPreferenceSerializer( data=request.data, context={"request": request} ) @@ -506,6 +556,21 @@ class PrincipalPreferenceDetailView(generics.RetrieveAPIView): ) +class EventPreferencesView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + model = EventCategory + serializer_class = EventCategorySerializer + + def get(self, request, *args, **kwargs): + """Get all event categories for the authenticated user.""" + obj = self.model.objects.filter(active=True, deleted=False) + serializer = self.serializer_class(obj, many=True, context={"request": request}) + return ApiResponse.success( + data=serializer.data, message=constants.SUCCESS, status=status.HTTP_200_OK + ) + + class EventMasterSearchAPIView(APIView): authentication_classes = [JWTAuthentication] permission_classes = [IsAuthenticated] @@ -626,9 +691,12 @@ class EventFilterByLocationAPIView(APIView): ) max_distance_km = 10 # Set your desired maximum distance - current_and_future_events_query = Q(active=True, deleted=False, draft=False) & ( - Q(end_date__gte=today) - ) + current_and_future_events_query = Q( + active=True, + deleted=False, + draft=False, + created_by__is_active=True, + ) & (Q(end_date__gte=today)) # Get the queryset based on the filter conditions events_queryset = Event.objects.filter(current_and_future_events_query) @@ -652,7 +720,7 @@ class EventFilterByLocationAPIView(APIView): venues_within_range.append(venue.id) print("venues_within_range: ", venues_within_range) # venues_data = [venue_to_dict(venue) for venue in venues_within_range] - events = Event.objects.filter(venue__id__in=venues_within_range) + events = events_queryset.filter(venue__id__in=venues_within_range) # Serialize and return the filtered events serializer = EventDetailSerializer( @@ -846,3 +914,270 @@ class TagListView(generics.ListAPIView): data=serializer.data, status=status.HTTP_200_OK, ) + + +class CaptureEventViewAPIView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + try: + event = Event.objects.get(pk=pk) + user = request.user + location_parts = [user.city, user.state, user.country] + location = " ".join(part for part in location_parts if part) + + EventView.objects.create(event=event, principal=user, location=location) + + return ApiResponse.success( + message=constants.SUCCESS, + data="Event view recorded successfully.", + status=status.HTTP_200_OK, + ) + except Event.DoesNotExist: + return ApiResponse.error( + message=constants.FAILURE, + errors="Event not found.", + status=status.HTTP_400_BAD_REQUEST, + ) + + +class EventShareView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + try: + event = Event.objects.get(id=pk) + except Event.DoesNotExist: + return ApiResponse.error( + message=constants.FAILURE, + errors="Event not found.", + status=status.HTTP_400_BAD_REQUEST, + ) + + # Incrementing the social media shares count + event.increment_shares() + + user = request.user # Assuming the user is authenticated + EventShare.objects.create(principal=user, event=event) + + return ApiResponse.success( + message=constants.SUCCESS, + data="Event shared successfully.", + status=status.HTTP_200_OK, + ) + +class AgeGroupListView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + serializer_class = AgeGroupsSerializer + model = AgeGroups + + def get(self, request): + queryset = self.model.objects.filter(active=True) + serializer = self.serializer_class(queryset, many=True) + return ApiResponse.success(message=constants.SUCCESS, data=serializer.data) + + +class EventsCalenderAPIView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + try: + principal = request.user + queryset = Event.objects.filter( + active=True, + draft=False, + deleted=False, + end_date__gte=timezone.now().date() + ) + + # queryset = Event.objects.filter( + # ((Q(active=True) & Q(draft=False) & Q(deleted=False) & Q(end_date__gte=timezone.now().date()))) + # ) + + preferences = PrincipalPreference.objects.get(principal=principal) + preferred_categories_ids = preferences.preferred_categories.values_list("id", flat=True) + + # Filter the queryset to only include events in the user's preferred categories + queryset = queryset.filter(Q(category__in=preferred_categories_ids) | Q(principal=principal)) + + serializer = EventListSerializer( + queryset, context={"request": request}, many=True + ) + return ApiResponse.success( + status=status.HTTP_200_OK, + message=constants.SUCCESS, + data=serializer.data, + ) + except Exception as e: + return ApiResponse.error( + status=status.HTTP_400_BAD_REQUEST, + message=constants.FAILURE, + errors=str(e), + ) + + +class EventListView(generics.ListAPIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + serializer_class = EventListSerializer + filter_backends = [DjangoFilterBackend] + filterset_class = EventFilter + + def get_queryset(self): + """ + Returns a queryset of events filtered by the user's preferences and subscription status. + """ + principal = self.request.user + + # Filter the base queryset to only include active, non-draft, non-deleted events with an end date in the future + queryset = Event.objects.filter( + active=True, + draft=False, + deleted=False, + end_date__gte=timezone.now().date() + ) + + # If no filter is applied and the user does not have a subscription, + # only show events that match the user's preferred categories + if not self.request.query_params or not PrincipalSubscription.has_principal_subscription(principal): + # Get the user's preferred categories + preferences = PrincipalPreference.objects.get(principal=principal) + preferred_categories_ids = preferences.preferred_categories.values_list("id", flat=True) + + # Filter the queryset to only include events in the user's preferred categories + queryset = queryset.filter(category__in=preferred_categories_ids).order_by("start_date") + + return queryset + + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + + # Get query parameters + latitude = self.request.query_params.get("latitude", "") + longitude = self.request.query_params.get("longitude", "") + ordering = self.request.query_params.get("sort", "") + + # Handle nearest location sorting + if latitude and longitude and "nearest" in ordering: + queryset = self.apply_nearest_filter(queryset, latitude, longitude) + + # Handle popularity and latest sorting + queryset = self.apply_sorting(queryset, ordering) + + return queryset + + def apply_nearest_filter(self, queryset, latitude, longitude): + gmaps_service = GoogleMapsservice() + return gmaps_service.get_nearest_events(queryset, float(latitude), float(longitude)) + + def apply_sorting(self, queryset, ordering): + # Split ordering fields and process each field + ordering_fields = [field.lstrip("-") for field in ordering.split(",")] + + # Remove 'nearest' from ordering as it's handled separately + if "nearest" in ordering_fields: + ordering_fields.remove("nearest") + ordering = ordering.replace("nearest", "") + + # Annotate with popularity and order it if requested + if "popularity" in ordering_fields: + queryset = queryset.annotate(popularity=Count("interaction_event")).order_by("-popularity") + + # order latest record and by default sorting + if "latest" in ordering_fields: + queryset = queryset.order_by("start_date") + + if "price" in ordering_fields: + queryset = queryset.order_by('entry_fee') + + return queryset + + def get(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return ApiResponse.success(message=constants.SUCCESS, data=serializer.data) + + +from rest_framework.response import Response +class SocialMediaPostView(APIView): + def get(self, request, *args, **kwargs): + platform = request.query_params.get("platform", "") + event_id = kwargs.get("id") + print(platform, event_id) + errors = [] + success_messages = [] + + try: + event = Event.objects.get(id=event_id) + except Event.DoesNotExist: + errors.append("Event does not exist") + return Response({ + 'message': "Error in posting to social media", + 'errors': errors, + 'success_messages': success_messages + }, status=400) + + if not event.active: + errors.append("Event is not active") + return Response({ + 'message': "Error in posting to social media", + 'errors': errors, + 'success_messages': success_messages + }, status=400) + + caption = f"{event.title}\nDuration: {event.start_date} to {event.end_date}\nAddress: {event.venue.address}" + + if platform in ['instagram', 'facebook', 'twitter', 'all']: + if platform in ['twitter', 'all']: + image_url = event.image.path + twitter_api = TwitterAPI() + twitter_poster = TwitterPoster(twitter_api) + result = twitter_poster.post_image_with_caption(image_url, caption) + if result['success']: + success_messages.append("Posted to Twitter successfully") + else: + errors.append("Fail to post on Twitter") + + image_url = request.build_absolute_uri(event.image.url) + if platform in ['facebook', 'all']: + facebook_api = FacebookAPI() + facebook_poster = FacebookPoster(facebook_api) + result = facebook_poster.post_photo(image_url, caption) + if result["success"]: + success_messages.append("Posted to Facebook successfully") + else: + errors.append("Fail to post on Facebook") + + if platform in ['instagram', 'all']: + instagram_api = InstagramAPI() + instagram_poster = InstagramPoster(instagram_api) + result = instagram_poster.post_image_with_caption(image_url, caption) + if result["success"]: + success_messages.append("Posted to Instagram successfully") + else: + errors.append("Fail to post on Instagram") + + if not errors: + return Response({'message': 'Post Successful', 'errors': errors, 'success_messages': success_messages}) + + if errors and success_messages: + return Response({ + 'message': 'Some posts succeeded while others failed', + 'errors': errors, + 'success_messages': success_messages + }, status=200) + + return Response({ + 'message': 'Error in posting to social media', + 'errors': errors, + 'success_messages': success_messages + }, status=400) \ No newline at end of file diff --git a/manage_events/forms.py b/manage_events/forms.py index 02d2e90..070f1cd 100644 --- a/manage_events/forms.py +++ b/manage_events/forms.py @@ -1,5 +1,6 @@ from django import forms -from manage_events.models import EventMaster, Event, EventCategory, Venue +from accounts.models import IAmPrincipal, IAmPrincipalExtendedData +from manage_events.models import AgeGroups, EventImage, EventMaster, Event, EventCategory, Venue from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ @@ -16,83 +17,121 @@ class EventCategoryForm(forms.ModelForm): class EventForm(forms.ModelForm): + principal = forms.ModelChoiceField( + queryset=IAmPrincipal.objects.select_related("extended_data").filter( + extended_data__is_onboarded=True, + extended_data__is_transferred=False + ), + label="Non-transfer user list", + required=True + ) + venue = forms.ModelChoiceField( + queryset=Venue.objects.none(), + label="venue", + required=True + ) + image = forms.ImageField(label="Thumbnail") + event_images = forms.ImageField(label="Event Images") + age_group = forms.ChoiceField( + choices=[], + label="Age Group", + required=True + ) + class Meta: model = Event fields = [ + "principal", + "venue", "title", # "event_master", "description", + "link", "image", - "status", + "event_images", + # "status", "start_date", "end_date", "from_time", "to_time", "category", - "venue", "venue_capacity", - "video_url", + # "video_url", "entry_type", "entry_fee", "key_guest", "age_group", + "coupon_code", + "coupon_description", "tags", "draft", "active", "deleted", ] widgets = { - "title": forms.TextInput(attrs={"class": "form-control"}), - "description": forms.Textarea(attrs={"class": "form-control", "rows": 4}), - "status": forms.Select(attrs={"class": "form-control"}), + "description": forms.Textarea(attrs={"rows": 4}), "start_date": forms.DateInput( - attrs={"class": "form-control", "type": "date"} + attrs={"type": "date"} ), "end_date": forms.DateInput( - attrs={"class": "form-control", "type": "date"} + attrs={"type": "date"} ), "from_time": forms.TimeInput( - attrs={"class": "form-control", "type": "time"} + attrs={"type": "time"} ), - "to_time": forms.TimeInput(attrs={"class": "form-control", "type": "time"}), - "venue_capacity": forms.NumberInput(attrs={"class": "form-control"}), - "video_url": forms.URLInput(attrs={"class": "form-control"}), - "entry_type": forms.TextInput(attrs={"class": "form-control"}), - "entry_fee": forms.NumberInput(attrs={"class": "form-control"}), - "key_guest": forms.Textarea(attrs={"class": "form-control", "rows": 3}), - "age_group": forms.TextInput(attrs={"class": "form-control"}), - "draft": forms.CheckboxInput(attrs={"class": "form-check-input"}), - # For the 'image' field, you might not need to specify a widget since the default is appropriate. - # However, if you want to add specific classes or attributes, you can do it like this: - "image": forms.FileInput(attrs={"class": "form-control-file"}), - # For ForeignKey fields like 'EventMaster' and 'venue', Django uses a select widget by default. - # You can customize it further if needed: - # "event_master": forms.Select(attrs={"class": "form-control"}), - "venue": forms.Select(attrs={"class": "form-control"}), - "category": forms.Select(attrs={"class": "form-control"}), + "to_time": forms.TimeInput(attrs={"type": "time"}), + "key_guest": forms.Textarea(attrs={"rows": 3}), + "coupon_description": forms.Textarea(attrs={"rows": 3}), } + def __init__(self, *args, **kwargs): + + instance = kwargs.get('instance') + principal_id = kwargs.pop('principal_id', None) + super().__init__(*args, **kwargs) + + if instance: + event_images = EventImage.objects.filter(event=instance) + if event_images.exists(): + self.fields['event_images'].initial = [image.image.url for image in event_images] + + # Set the initial value for age_group if instance is provided + print(f"age group is {self.instance.age_group}") + age_groups = [(age_group.name, age_group.name) for age_group in AgeGroups.objects.filter(active=True)] + self.fields['age_group'].choices = age_groups + + if self.instance: + self.fields['age_group'].initial = self.instance.age_group + + if principal_id: + self.fields['venue'].queryset = Venue.objects.filter(principal_id=principal_id, active=True) + else: + self.fields['venue'].queryset = Venue.objects.none() + def clean(self): cleaned_data = super().clean() # Get the start and end dates from cleaned_data start_date = cleaned_data.get("start_date") end_date = cleaned_data.get("end_date") - - # Validation 1: end_date should not be less than start_date - if end_date and start_date and end_date < start_date: - self.add_error("end_date", _("End date cannot be before the start date.")) - # Get the from and to times from cleaned_data from_time = cleaned_data.get("from_time") to_time = cleaned_data.get("to_time") - # Validation 2: to_time should not be less than or equal to from_time - if to_time and from_time and to_time <= from_time: - self.add_error("to_time", _("End time must be after the start time.")) + # Validation 1: end_date should not be less than start_date + if start_date and end_date and end_date < start_date: + self.add_error("end_date", _("End date cannot be before the start date.")) + + if end_date == start_date: + if to_time and from_time and to_time <= from_time: + self.add_error("to_time", _("End time must be after the start time.")) return cleaned_data +class EventImageForm(forms.ModelForm): + class Meta: + model = EventImage + fields = ['image'] class EventMasterForm(forms.ModelForm): class Meta: @@ -101,23 +140,35 @@ class EventMasterForm(forms.ModelForm): class VenueForm(forms.ModelForm): + principal = forms.ModelChoiceField( + queryset=IAmPrincipal.objects.select_related("extended_data").filter( + extended_data__is_onboarded=True, + extended_data__is_transferred=False + ), + label="Non-transfer user list", + required=True + ) + image = forms.ImageField(required=True) + postcode = forms.CharField(required=True, max_length=10) + latitude = forms.DecimalField( + widget=forms.NumberInput() + ) + longitude = forms.DecimalField( + widget=forms.NumberInput() + ) + class Meta: model = Venue fields = [ + "principal", "title", - "description", "address", + "postcode", "image", - "url", "latitude", "longitude", ] widgets = { - "title": forms.TextInput(attrs={"class": "form-control"}), - "description": forms.Textarea(attrs={"class": "form-control", "rows": 4}), - "address": forms.Textarea(attrs={"class": "form-control", "rows": 3}), - "image": forms.FileInput(attrs={"class": "form-control"}), - "url": forms.URLInput(attrs={"class": "form-control"}), - "latitude": forms.NumberInput(attrs={"class": "form-control"}), - "longitude": forms.NumberInput(attrs={"class": "form-control"}), + "description": forms.Textarea(attrs={"rows": 4}), + "address": forms.Textarea(attrs={"rows": 3}), } diff --git a/manage_events/management/commands/get_specific_manager_report.py b/manage_events/management/commands/get_specific_manager_report.py new file mode 100644 index 0000000..97a8adb --- /dev/null +++ b/manage_events/management/commands/get_specific_manager_report.py @@ -0,0 +1,60 @@ +from django.core.mail import EmailMessage +from django.conf import settings +from django.core.management.base import BaseCommand +from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta +from django.utils.timezone import now +from django.contrib.auth import get_user_model +import calendar + +from manage_events.report import generate_event_report, generate_event_report_pdf_three + +class Command(BaseCommand): + help = 'Getting event reports of specific event managers' + + def add_arguments(self, parser): + parser.add_argument('month', type=int, help='Month number (1-12)') + parser.add_argument('email', type=str, help='User email address') + parser.add_argument('mail_send_on', type=str, help='Email to send the report') + + def handle(self, *args, **kwargs): + month = kwargs['month'] + email = kwargs['email'] + mail_send_on = kwargs['mail_send_on'] + + # Validate the month + if month < 1 or month > 12: + self.stdout.write(self.style.ERROR("Invalid month. Must be between 1 and 12.")) + return + + User = get_user_model() + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + self.stdout.write(self.style.ERROR(f"User with email {email} does not exist.")) + return + + # Calculate start and end dates of the month + year = now().year # Assuming the current year + start_date = datetime(year, month, 1) + last_day = calendar.monthrange(year, month)[1] + end_date = datetime(year, month, last_day) + + report_data = generate_event_report(user.id, start_date, end_date) + if report_data: + pdf_data, filename = generate_event_report_pdf_three(user, report_data, start_date) + self.send_email_with_attachment(mail_send_on, pdf_data, filename) + + def send_email_with_attachment(self, email, pdf_data, filename): + try: + email_message = EmailMessage( + subject="Monthly Event Report", + body="Please find the attached report for the last month.", + to=[email], + from_email=settings.DEFAULT_FROM_EMAIL, + ) + email_message.attach(filename, pdf_data, "application/pdf") + email_message.send() + self.stdout.write(self.style.SUCCESS(f"Email successfully sent to {email} with attachment {filename}.")) + except Exception as e: + self.stdout.write(self.style.ERROR(f"Failed to send email to {email}. Error: {str(e)}")) diff --git a/manage_events/management/commands/manager_report.py b/manage_events/management/commands/manager_report.py new file mode 100644 index 0000000..c2117bc --- /dev/null +++ b/manage_events/management/commands/manager_report.py @@ -0,0 +1,33 @@ +from django.core.management.base import BaseCommand +from django.core.mail import EmailMessage +from django.conf import settings +from manage_events.report import ( + get_previous_month_date_range, + event_managers, + generate_event_report, + generate_event_report_pdf_three, +) + + +class Command(BaseCommand): + help = "Send monthly event reports to event managers" + + def handle(self, *args, **kwargs): + start_date, end_date = get_previous_month_date_range() + users = event_managers() + + for user in users: + report_data = generate_event_report(user.id, start_date, end_date) + if report_data: + pdf_data, filename = generate_event_report_pdf_three(user, report_data) + self.send_email_with_attachment(user.email, pdf_data, filename) + + def send_email_with_attachment(self, email, pdf_data, filename): + email_message = EmailMessage( + subject="Monthly Event Report", + body="Please find the attached report for the last month.", + to=[email], + from_email=settings.DEFAULT_FROM_EMAIL, + ) + email_message.attach(filename, pdf_data, "application/pdf") + email_message.send() diff --git a/manage_events/management/commands/populate_age_group.py b/manage_events/management/commands/populate_age_group.py new file mode 100644 index 0000000..b55cceb --- /dev/null +++ b/manage_events/management/commands/populate_age_group.py @@ -0,0 +1,20 @@ +from django.core.management.base import BaseCommand +from ...models import AgeGroups, Event +import random + +class Command(BaseCommand): + help = 'Populate the AgeGroup model with predefined age groups' + + def handle(self, *args, **kwargs): + age_groups = ["18-21", "21-30", "30-40", "40-50", "50+"] + for age in age_groups: + age_group, created = AgeGroups.objects.get_or_create(name=age) + if created: + self.stdout.write(self.style.SUCCESS(f'Age group "{age}" created.')) + else: + self.stdout.write(self.style.WARNING(f'Age group "{age}" already exists.')) + + # Update all Event objects with a random age group + for event in Event.objects.all(): + event.age_group = random.choice(age_groups) + event.save() diff --git a/manage_events/management/commands/test_facebook_api.py b/manage_events/management/commands/test_facebook_api.py new file mode 100644 index 0000000..5f0a2e3 --- /dev/null +++ b/manage_events/management/commands/test_facebook_api.py @@ -0,0 +1,30 @@ +import os +from django.conf import settings +from django.core.management.base import BaseCommand +from goodtimes.services import FacebookAPI, FacebookPoster +from ...models import Event + +class Command(BaseCommand): + help = 'Test facebook posting functionality' + + def handle(self, *args, **kwargs): + event = Event.objects.get(id=20) + if not event: + self.stdout.write(self.style.ERROR("No event found.")) + + if not event.image: + self.stdout.write(self.style.ERROR("No image found.")) + + image_path = f"{settings.BASE_DOMAIN}{event.image.url}" + print(f"complete path of image {image_path}") + caption = f"{event.title}\nDuration: {event.start_date} to {event.end_date}\nAddress: {event.venue.address}" + + facebook_api = FacebookAPI() + facebook_poster = FacebookPoster(facebook_api) + + response = facebook_poster.post_photo(image_path, caption) + + if response['success']: + self.stdout.write(self.style.SUCCESS(response['message'])) + else: + self.stdout.write(self.style.ERROR(response['message'])) diff --git a/manage_events/management/commands/test_instagram_api.py b/manage_events/management/commands/test_instagram_api.py new file mode 100644 index 0000000..a5c689e --- /dev/null +++ b/manage_events/management/commands/test_instagram_api.py @@ -0,0 +1,33 @@ +import os +from django.conf import settings +from django.core.management.base import BaseCommand +from goodtimes.services import InstagramAPI, InstagramPoster +from ...models import Event +import urllib.request + +class Command(BaseCommand): + help = 'Test Instagram posting functionality' + + def handle(self, *args, **kwargs): + event = Event.objects.get(id=20) + if not event: + self.stdout.write(self.style.ERROR("No event found.")) + + if not event.image: + self.stdout.write(self.style.ERROR("No image found.")) + + image_path = f"{settings.BASE_DOMAIN}{event.image.url}" + # image_path = event.image.url + print(f"complete path of image {image_path}") + + caption = f"{event.title}\nDuration: {event.start_date} to {event.end_date}\nAddress: {event.venue.address}" + + instagram_api = InstagramAPI() + instagram_poster = InstagramPoster(instagram_api) + + response = instagram_poster.post_image_with_caption(image_path, caption) + + if response['success']: + self.stdout.write(self.style.SUCCESS(response['message'])) + else: + self.stdout.write(self.style.ERROR(response['message'])) diff --git a/manage_events/management/commands/test_twitter_api.py b/manage_events/management/commands/test_twitter_api.py new file mode 100644 index 0000000..2c79553 --- /dev/null +++ b/manage_events/management/commands/test_twitter_api.py @@ -0,0 +1,28 @@ +import os +from django.core.management.base import BaseCommand +from goodtimes.services import TwitterAPI, TwitterPoster +from ...models import Event + +class Command(BaseCommand): + help = 'Test Twitter posting functionality' + + def handle(self, *args, **kwargs): + event = Event.objects.get(id=19) + if not event: + self.stdout.write(self.style.ERROR("No event found.")) + + if not event.image: + self.stdout.write(self.style.ERROR("No image found.")) + + image_path = event.image.path + caption = f"{event.title}\nDuration: {event.start_date} to {event.end_date}\nAddress: {event.venue.address}" + + twitter_api = TwitterAPI() + twitter_poster = TwitterPoster(twitter_api) + + response = twitter_poster.post_image_with_caption(image_path, caption) + + if response['success']: + self.stdout.write(self.style.SUCCESS(response['message'])) + else: + self.stdout.write(self.style.ERROR(response['message'])) diff --git a/manage_events/management/commands/update_facebook_tokens.py b/manage_events/management/commands/update_facebook_tokens.py new file mode 100644 index 0000000..901df24 --- /dev/null +++ b/manage_events/management/commands/update_facebook_tokens.py @@ -0,0 +1,103 @@ + +import os +from django.conf import settings +import requests +from dotenv import load_dotenv +from django.core.management.base import BaseCommand + +# Load .env variables +load_dotenv() + +class Command(BaseCommand): + help = 'Update Facebook long-lived access tokens and page access token' + + def __init__(self): + super().__init__() + self.app_id = settings.FACEBOOK_APP_ID + self.app_secret = settings.FACEBOOK_APP_SECRET + self.page_id = settings.FACEBOOK_PAGE_ID + self.graph_api_version = settings.FACEBOOK_GRAPH_VERSION_API + self.page_access_token = settings.FACEBOOK_ACCESS_TOKEN + self.long_lived_token = settings.FACEBOOK_ACCESS_TOKEN + + def handle(self, *args, **kwargs): + """Handle the token refresh and update .env file.""" + if self.refresh_access_tokens(): + self.stdout.write(self.style.SUCCESS("Successfully refreshed Facebook tokens.")) + else: + self.stdout.write(self.style.ERROR("Failed to refresh Facebook tokens.")) + + def _exchange_short_to_long_lived_token(self, short_lived_token): + """Exchange short-lived token for long-lived token.""" + try: + url = f"https://graph.facebook.com/{self.graph_api_version}/oauth/access_token" + params = { + "grant_type": "fb_exchange_token", + "client_id": self.app_id, + "client_secret": self.app_secret, + "fb_exchange_token": short_lived_token, + } + response = requests.get(url, params=params) + response.raise_for_status() + long_lived_token = response.json().get("access_token") + self.stdout.write(self.style.SUCCESS("Successfully exchanged for long-lived user access token.")) + return long_lived_token + except requests.exceptions.RequestException as e: + self.stdout.write(self.style.ERROR(f"Error exchanging short-lived token: {e}")) + return None + + def _get_page_access_token(self, user_token): + """Retrieve Page Access Token.""" + try: + url = f"https://graph.facebook.com/{self.graph_api_version}/{self.page_id}" + params = { + "fields": "access_token", + "access_token": user_token, + } + response = requests.get(url, params=params) + response.raise_for_status() + page_access_token = response.json().get("access_token") + self.stdout.write(self.style.SUCCESS("Successfully obtained page access token.")) + return page_access_token + except requests.exceptions.RequestException as e: + self.stdout.write(self.style.ERROR(f"Error retrieving page access token: {e}")) + return None + + def _update_env_variable(self, key, value): + """Update a variable in the .env file.""" + with open('.env', 'r') as file: + lines = file.readlines() + + with open('.env', 'w') as file: + updated = False + for line in lines: + if line.startswith(key): + file.write(f"{key}={value}\n") + updated = True + else: + file.write(line) + if not updated: + file.write(f"{key}={value}\n") + + def refresh_access_tokens(self): + """Refresh long-lived user access token and page access token.""" + if not self.long_lived_token: + self.stdout.write(self.style.ERROR("No valid long-lived user access token found.")) + return False + + # Refresh long-lived user token (optional, based on expiry) + refreshed_user_token = self._exchange_short_to_long_lived_token(self.long_lived_token) + if refreshed_user_token: + self.long_lived_token = refreshed_user_token + + # Refresh page access token + # page_access_token = self._get_page_access_token(self.long_lived_token) + # if page_access_token: + # self.page_access_token = page_access_token + # # Update tokens in .env file + self._update_env_variable("FACEBOOK_ACCESS_TOKEN", self.long_lived_token) + # self._update_env_variable("FACEBOOK_PAGE_ACCESS_TOKEN", self.page_access_token) + return True + + self.stdout.write(self.style.ERROR("Failed to refresh page access token.")) + return False diff --git a/manage_events/migrations/0009_eventview.py b/manage_events/migrations/0009_eventview.py new file mode 100644 index 0000000..ee512af --- /dev/null +++ b/manage_events/migrations/0009_eventview.py @@ -0,0 +1,75 @@ +# Generated by Django 5.0.2 on 2024-05-31 11:31 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("manage_events", "0008_alter_eventprincipalinteraction_event_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="EventView", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("view_date", models.DateTimeField(auto_now_add=True)), + ("location", models.CharField(blank=True, max_length=255, null=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="views", + to="manage_events.event", + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="event_views", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/manage_events/migrations/0010_event_social_media_shares_count_eventshare.py b/manage_events/migrations/0010_event_social_media_shares_count_eventshare.py new file mode 100644 index 0000000..4c6c7a4 --- /dev/null +++ b/manage_events/migrations/0010_event_social_media_shares_count_eventshare.py @@ -0,0 +1,78 @@ +# Generated by Django 5.0.2 on 2024-06-01 15:06 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("manage_events", "0009_eventview"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="social_media_shares_count", + field=models.IntegerField(default=0), + ), + migrations.CreateModel( + name="EventShare", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="social_media_shares", + to="manage_events.event", + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "principal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="event_shares", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/manage_events/migrations/0011_alter_event_entry_type.py b/manage_events/migrations/0011_alter_event_entry_type.py new file mode 100644 index 0000000..f5b8390 --- /dev/null +++ b/manage_events/migrations/0011_alter_event_entry_type.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.2 on 2024-06-04 10:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("manage_events", "0010_event_social_media_shares_count_eventshare"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="entry_type", + field=models.CharField( + choices=[("free", "Free"), ("paid", "Paid")], max_length=10 + ), + ), + ] diff --git a/manage_events/migrations/0012_event_coupon_code_event_coupon_description.py b/manage_events/migrations/0012_event_coupon_code_event_coupon_description.py new file mode 100644 index 0000000..4fc4935 --- /dev/null +++ b/manage_events/migrations/0012_event_coupon_code_event_coupon_description.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.2 on 2024-06-20 06:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("manage_events", "0011_alter_event_entry_type"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="coupon_code", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="event", + name="coupon_description", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/manage_events/migrations/0013_venue_principal.py b/manage_events/migrations/0013_venue_principal.py new file mode 100644 index 0000000..6a32b53 --- /dev/null +++ b/manage_events/migrations/0013_venue_principal.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.2 on 2024-06-25 17:04 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_events', '0012_event_coupon_code_event_coupon_description'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='venue', + name='principal', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='venues_principal', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/manage_events/migrations/0014_event_principal.py b/manage_events/migrations/0014_event_principal.py new file mode 100644 index 0000000..15109c4 --- /dev/null +++ b/manage_events/migrations/0014_event_principal.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.2 on 2024-06-25 17:09 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_events', '0013_venue_principal'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='principal', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='events_principal', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/manage_events/migrations/0015_agegroups.py b/manage_events/migrations/0015_agegroups.py new file mode 100644 index 0000000..32fcd12 --- /dev/null +++ b/manage_events/migrations/0015_agegroups.py @@ -0,0 +1,32 @@ +# Generated by Django 5.0.2 on 2024-07-17 06:36 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_events', '0014_event_principal'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AgeGroups', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('active', models.BooleanField(default=True)), + ('deleted', models.BooleanField(default=False)), + ('created_on', models.DateTimeField(auto_now_add=True)), + ('modified_on', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=10, unique=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'age_group', + }, + ), + ] diff --git a/manage_events/migrations/0016_freeusagefeaturelimit.py b/manage_events/migrations/0016_freeusagefeaturelimit.py new file mode 100644 index 0000000..0d87275 --- /dev/null +++ b/manage_events/migrations/0016_freeusagefeaturelimit.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.2 on 2024-08-05 10:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_events', '0015_agegroups'), + ] + + operations = [ + migrations.CreateModel( + name='FreeUsageFeatureLimit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category_limit', models.PositiveIntegerField(default=3, help_text='The maximum number of categories that free app users can select.')), + ], + options={ + 'verbose_name': 'Free Usage Feature Limit', + 'verbose_name_plural': 'Free Usage Feature Limits', + 'db_table': 'free_usage_feature_limit', + }, + ), + ] diff --git a/manage_events/migrations/0017_venue_postcode.py b/manage_events/migrations/0017_venue_postcode.py new file mode 100644 index 0000000..8eb3bef --- /dev/null +++ b/manage_events/migrations/0017_venue_postcode.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-12-20 09:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_events', '0016_freeusagefeaturelimit'), + ] + + operations = [ + migrations.AddField( + model_name='venue', + name='postcode', + field=models.CharField(blank=True, max_length=20, null=True), + ), + ] diff --git a/manage_events/migrations/0018_event_link.py b/manage_events/migrations/0018_event_link.py new file mode 100644 index 0000000..2721ecb --- /dev/null +++ b/manage_events/migrations/0018_event_link.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-12-24 11:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_events', '0017_venue_postcode'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='link', + field=models.URLField(blank=True, max_length=255, null=True), + ), + ] diff --git a/manage_events/models.py b/manage_events/models.py index 4f876cb..9be9dd4 100644 --- a/manage_events/models.py +++ b/manage_events/models.py @@ -1,12 +1,33 @@ from django.db import models +from django.core.exceptions import ValidationError from accounts.models import BaseModel, IAmPrincipal from django.db import transaction from taggit.managers import TaggableManager -# from django.contrib.gis.db import models as gis_models +class FreeUsageFeatureLimit(models.Model): + category_limit = models.PositiveIntegerField( + default=3, + help_text="The maximum number of categories that free app users can select." + ) + + class Meta: + db_table = "free_usage_feature_limit" + verbose_name = "Free Usage Feature Limit" + verbose_name_plural = "Free Usage Feature Limits" + + def __str__(self): + return f"Free usage limit: {self.category_limit} categories" + + def save(self, *args, **kwargs): + if not self.pk and FreeUsageFeatureLimit.objects.exists(): + raise ValidationError("There can only be one FreeUsageFeatureLimit instance.") + return super().save(*args, **kwargs) + + @classmethod + def get_category_limit(cls): + return cls.objects.values_list('category_limit', flat=True).first() -# Create your models here. class EventCategory(BaseModel): title = models.CharField(max_length=255) image = models.ImageField(upload_to="event_category", null=True, blank=True) @@ -18,6 +39,7 @@ class EventCategory(BaseModel): class Venue(BaseModel): + principal = models.ForeignKey(IAmPrincipal, related_name="venues_principal", on_delete=models.CASCADE, null=True) title = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) address = models.TextField(null=True, blank=True) @@ -29,10 +51,20 @@ class Venue(BaseModel): longitude = models.DecimalField( max_digits=14, decimal_places=8, blank=True, null=True ) + postcode = models.CharField(max_length=20, blank=True, null=True) def __str__(self): return self.title +class AgeGroups(BaseModel): + name = models.CharField(max_length=10, unique=True) + + class Meta: + db_table = "age_group" + + def __str__(self): + return self.name + class EventStatus(models.TextChoices): UPCOMING = "upcoming", "Upcoming" @@ -55,6 +87,11 @@ class EventMaster(BaseModel): class Event(BaseModel): + ENTRY_TYPE_CHOICES = [ + ("free", "Free"), + ("paid", "Paid"), + ] + principal = models.ForeignKey(IAmPrincipal, related_name="events_principal", on_delete=models.CASCADE, null=True) title = models.CharField(max_length=255) category = models.ForeignKey(EventCategory, on_delete=models.CASCADE) event_master = models.ForeignKey( @@ -75,15 +112,37 @@ class Event(BaseModel): video_url = models.URLField(max_length=200, blank=True, null=True) entry_type = models.CharField( - max_length=100 - ) # Assuming entry type is a string (e.g., Free, Ticketed) + max_length=10, + choices=ENTRY_TYPE_CHOICES, + ) entry_fee = models.DecimalField( max_digits=14, decimal_places=2, default=0.00 - ) # Assuming it's an integer. Use DecimalField if you need to handle cents. + ) key_guest = models.TextField(blank=True, null=True) tags = TaggableManager(blank=True) age_group = models.CharField(max_length=100, blank=True, null=True) draft = models.BooleanField(default=False) + social_media_shares_count = models.IntegerField(default=0) + coupon_code = models.CharField(max_length=255, blank=True, null=True) + coupon_description = models.TextField(blank=True, null=True) + link = models.URLField(max_length=255, blank=True, null=True) + + def increment_shares(self): + self.social_media_shares_count += 1 + self.save() + + def set_key_guests(self, guests): + """Set the key guests as a comma-seperated string.""" + if isinstance(guests, list): + self.key_guest = ",".join(guests) + elif isinstance(guests, str): + self.key_guest = guests + else: + raise ValueError("Guests must be a comma-seperated string") + + def get_key_guests(self): + """Return the key guests as a list of strings.""" + return self.key_guest.split(",") if self.key_guest else [] def __str__(self): return self.title @@ -132,7 +191,7 @@ class PrincipalPreference(BaseModel): ) def __str__(self): - return str(self.preferred_categories) + return str(self.preferred_categories.name) class Meta: db_table = "user_preference" @@ -173,3 +232,26 @@ class EventReview(BaseModel): def __str__(self): return f"Review by {self.principal} on {self.event}" + + +class EventView(BaseModel): + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="views") + principal = models.ForeignKey( + IAmPrincipal, on_delete=models.CASCADE, related_name="event_views" + ) + view_date = models.DateTimeField(auto_now_add=True) + location = models.CharField( + max_length=255, blank=True, null=True + ) # Or use a more complex field for location data + + def __str__(self): + return f"{self.principal.email} viewed {self.event.title} from {self.location}" + + +class EventShare(BaseModel): + event = models.ForeignKey( + Event, on_delete=models.CASCADE, related_name="social_media_shares" + ) + principal = models.ForeignKey( + IAmPrincipal, on_delete=models.CASCADE, related_name="event_shares" + ) diff --git a/manage_events/report.py b/manage_events/report.py new file mode 100644 index 0000000..70c0d75 --- /dev/null +++ b/manage_events/report.py @@ -0,0 +1,378 @@ +from django.db.models import Count, Q +from django.utils import timezone +from datetime import timedelta +from reportlab.lib.pagesizes import letter +from reportlab.lib.units import mm +from reportlab.graphics.shapes import Drawing, Rect +from reportlab.lib import colors +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.graphics.charts.piecharts import Pie +from reportlab.platypus import ( + SimpleDocTemplate, + Table, + TableStyle, + Paragraph, + Spacer, + PageBreak, + Image, +) +from io import BytesIO +from django.conf import settings +from collections import defaultdict +from reportlab.graphics import renderPDF +from django.contrib.auth import get_user_model +from accounts.models import IAmPrincipalType +from manage_events.models import Event, EventInteractionType, EventShare, EventView +import os + + +User = get_user_model() + + +def generate_filename(email, date): + # Extract the username from the email address + username = email.split("@")[0] + # Get the full month name from the date + month_name = date.strftime("%B") + # Create the filename + filename = f"{username}_{month_name}_report.pdf" + return filename + + +def event_managers(): + principal_type = IAmPrincipalType.objects.filter(name="event_manager").first() + return User.objects.filter(principal_type=principal_type, is_active=True) + + +def get_previous_month_date_range(): + today = timezone.now() + first_day_of_current_month = today.replace(day=1) + last_day_of_previous_month = first_day_of_current_month - timedelta(days=1) + first_day_of_previous_month = last_day_of_previous_month.replace(day=1) + return first_day_of_previous_month, last_day_of_previous_month + + +def generate_event_report(user_id, start_date, end_date): + # start_date, end_date = get_previous_month_date_range() + user = User.objects.get(id=user_id) + + # events = Event.objects.filter( + # created_by=user, start_date__gte=start_date, start_date__lte=end_date + # ).annotate( + # favorites_count=Count("favorites"), + # interested_count=Count( + # "interaction_event", + # filter=Q(interaction_event__status=EventInteractionType.INTERESTED), + # ), + # going_count=Count( + # "interaction_event", + # filter=Q(interaction_event__status=EventInteractionType.GOING), + # ), + # reviews_count=Count( + # "reviews", filter=Q(reviews__active=True, reviews__deleted=False) + # ), + # views_count=Count("views"), + # ) + # # print("events: ", events) + + # report_data = [] + # for event in events: + # views = EventView.objects.filter(event=event) + # locations = defaultdict(int) + # for view in views: + # locations[view.location] += 1 + + # shares = ( + # EventShare.objects.filter(event=event) + # .values("principal") + # .annotate(share_count=Count("principal")) + # ) + # shares_data = { + # User.objects.get(id=share["principal"]).get_full_name(): share[ + # "share_count" + # ] + # for share in shares + # } + + # report_data.append( + # { + # "event_name": event.title, + # "event_type": event.category.title, + # "event_date": str(event.start_date), + # "favorites_count": event.favorites_count, + # "interested_count": event.interested_count, + # "going_count": event.going_count, + # "reviews_count": event.reviews_count, + # "views_count": event.views_count, + # "locations": dict(locations), + # "social_media_shares": event.social_media_shares_count, + # "shares_data": shares_data, + # } + # ) + # # print("report_data: ", report_data) + # return report_data + events = Event.objects.filter( + created_by=user, start_date__gte=start_date, start_date__lte=end_date + ) + + report_data = [] + for event in events: + # Collecting individual counts for each event + favorites_count = event.favorites.count() + interested_count = event.interaction_event.filter( + status=EventInteractionType.INTERESTED + ).count() + going_count = event.interaction_event.filter( + status=EventInteractionType.GOING + ).count() + reviews_count = event.reviews.filter(active=True, deleted=False).count() + views_count = event.views.count() + + # Collecting views and locations + views = EventView.objects.filter(event=event) + locations = defaultdict(int) + for view in views: + locations[view.location] += 1 + + # Collecting shares data + shares = ( + EventShare.objects.filter(event=event) + .values("principal") + .annotate(share_count=Count("principal")) + ) + shares_data = { + User.objects.get(id=share["principal"]).get_full_name(): share[ + "share_count" + ] + for share in shares + } + + # Appending event data to report + report_data.append( + { + "event_name": event.title, + "event_type": event.category.title, + "event_date": str(event.start_date), + "favorites_count": favorites_count, + "interested_count": interested_count, + "going_count": going_count, + "reviews_count": reviews_count, + "views_count": views_count, + "locations": dict(locations), + "social_media_shares": event.social_media_shares_count, + "shares_data": shares_data, + } + ) + + return report_data + + +def generate_event_report_pdf_three(user, report_data, start_date): + # start_date, _ = get_previous_month_date_range() + filename = generate_filename(user.email, start_date) + + buffer = BytesIO() + # pdf = canvas.Canvas(buffer, pagesize=letter) + pdf = SimpleDocTemplate(buffer, pagesize=letter) + width, height = letter + elements = [] + + styles = getSampleStyleSheet() + + custom_style = ParagraphStyle( + name="Custom", + parent=styles["Normal"], + fontName="Helvetica", + fontSize=14, + leading=18, + spaceAfter=12, + ) + + def add_page_number(canvas, doc): + page_num_text = f"Page {doc.page}" + canvas.drawRightString(200 * mm, 10 * mm, page_num_text) + + # Header Section + title = Paragraph("Good Times Ltd. Monthly Report", styles["Title"]) + report_for_month = Paragraph( + f"Report for the month of - {start_date.strftime('%B %Y')}", styles["Title"] + ) + organiser_name = Paragraph( + f"Name of the Organiser - {user.get_full_name()}", styles["Title"] + ) + contact_email = Paragraph(f"Contact Email - {user.email}", styles["Title"]) + + elements.extend( + [ + title, + Spacer(1, 12), + report_for_month, + Spacer(1, 12), + organiser_name, + Spacer(1, 12), + contact_email, + Spacer(1, 72), # Add space before the logo + ] + ) + + # Insert company logo + logo_path = settings.LOGO_PATH + print("logo_path: ", logo_path) + logo_path = os.path.join(str(logo_path), "images/icon.png") # Path to the logo + logo = Image(logo_path) + logo.drawWidth = 200 # Adjust the width as needed + logo.drawHeight = 300 # Adjust the height as needed + logo.hAlign = "CENTER" # Center the logo + + elements.append(logo) + elements.append(Spacer(1, 12)) # Add space after the logo + + # Page break after header and logo + elements.append(PageBreak()) + + # Summary Section + summary_text = ( + f"Number of Events added in {start_date.strftime('%B %Y')} - {len(report_data)}" + ) + elements.append(Paragraph(summary_text, styles["Title"])) + elements.append(Spacer(1, 24)) + + data = [["Sr No.", "Name of the Event", "Event Type", "Date"]] + for idx, event in enumerate(report_data, start=1): + data.append( + [ + idx, + event["event_name"], + event["event_type"], + event["event_date"], + ] + ) + + table = Table(data, colWidths=[50, 250, 150, 100]) + style = TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.grey), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("BOTTOMPADDING", (0, 0), (-1, 0), 12), + ("BACKGROUND", (0, 1), (-1, -1), colors.beige), + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ] + ) + table.setStyle(style) + + elements.append(table) + elements.append(PageBreak()) + + # Traffic Details Section + traffic_header = "Traffic Details for profile - Event Organisers London Ltd." + elements.append(Paragraph(traffic_header, styles["Title"])) + elements.append(Spacer(1, 12)) + + views_count = sum(event["views_count"] for event in report_data) + favorites_count = sum(event["favorites_count"] for event in report_data) + social_media_shares = sum(event["social_media_shares"] for event in report_data) + + elements.append(Paragraph(f"Number of Event Views - {views_count}", custom_style)) + elements.append(Spacer(1, 12)) + elements.append( + Paragraph(f"Number of Event Favorites - {favorites_count}", custom_style) + ) + elements.append(Spacer(1, 12)) + elements.append( + Paragraph(f"Social Media Shares - {social_media_shares}", custom_style) + ) + # elements.append(PageBreak()) + elements.append(Spacer(1, 60)) + + # Top 5 Locations and Top 5 Viewed Events + all_locations = defaultdict(int) + for event in report_data: + for location, count in event["locations"].items(): + all_locations[location] += count + # top 5 events by location + top_locations = sorted(all_locations.items(), key=lambda x: x[1], reverse=True)[:5] + + # top 5 events overall by views + top_events_by_views = sorted( + report_data, key=lambda x: x["views_count"], reverse=True + )[:5] + + data = [ + ["Top 5 Locations Viewed From", "Top 5 Viewed Events"], + ] + + for i in range(5): + location = top_locations[i][0] if i < len(top_locations) else "" + event = ( + top_events_by_views[i]["event_name"] if i < len(top_events_by_views) else "" + ) + data.append([location, event]) + + table = Table(data, colWidths=[200, 200]) + style = TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.grey), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("BOTTOMPADDING", (0, 0), (-1, 0), 12), + ("BACKGROUND", (0, 1), (-1, -1), colors.beige), + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ] + ) + table.setStyle(style) + + elements.append(table) + elements.append(PageBreak()) + + # Detailed Review of Each Event + for event in report_data: + elements.append( + Paragraph(f"Event Name - {event['event_name']}", styles["Heading1"]) + ) + elements.append(Spacer(1, 12)) + elements.append(Paragraph(f"Event Type - {event['event_type']}", custom_style)) + elements.append(Paragraph(f"Event Date - {event['event_date']}", custom_style)) + + views = f"Number of Views - {event['views_count']}" + favorites = f"Favorites Count - {event['favorites_count']}" + interested = f"Interested in Going - {event['interested_count']}" + going = f"Going - {event['going_count']}" + reviews = f"Reviews - {event['reviews_count']}" + shares = f"Social Media Shares - {event['social_media_shares']}" + + elements.append(Paragraph(views, custom_style)) + elements.append(Paragraph(shares, custom_style)) + elements.append(Paragraph(favorites, custom_style)) + elements.append(Paragraph(interested, custom_style)) + elements.append(Paragraph(going, custom_style)) + elements.append(Paragraph(reviews, custom_style)) + elements.append(Spacer(1, 48)) + + pie_data = [ + event["views_count"], + event["social_media_shares"], + event["favorites_count"], + event["interested_count"], + event["going_count"], + ] + pie_labels = ["Views", "Shares", "Favorites", "Interested", "Going"] + + drawing = Drawing(300, 200) + pie = Pie() + pie.data = pie_data + pie.labels = pie_labels + pie.width = 150 + pie.height = 150 + pie.x = 75 + pie.y = 25 + drawing.add(pie) + elements.append(drawing) + elements.append(PageBreak()) + pdf.build(elements, onFirstPage=add_page_number, onLaterPages=add_page_number) + buffer.seek(0) + pdf_data = buffer.read() + buffer.close() + return pdf_data, filename diff --git a/manage_events/urls.py b/manage_events/urls.py index 543d40b..1f52b7b 100644 --- a/manage_events/urls.py +++ b/manage_events/urls.py @@ -89,4 +89,16 @@ urlpatterns = [ views.VenueDeleteView.as_view(), name="venue_delete", ), + path( + "venue/customer/", + views.CustomerVenueFilterView.as_view(), + name="venue_customer_filter", + ), + path( + "generate-event-report//", + views.GenerateEventReportView.as_view(), + name="generate_event_report", + ), + + path("post-to-social-media///", views.SocialMediaPostView.as_view(), name="social_media_post") ] diff --git a/manage_events/utils.py b/manage_events/utils.py index 1037987..8600c50 100644 --- a/manage_events/utils.py +++ b/manage_events/utils.py @@ -1,4 +1,5 @@ import math +from accounts.models import IAmPrincipal from manage_events.models import Event, Venue from django.utils.timezone import now import googlemaps @@ -104,3 +105,23 @@ def get_location_info(latitude, longitude): } else: return {} + + +def update_principal_location(principal, latitude, longitude): + location_data = get_location_info(latitude=latitude, longitude=longitude) + + city = location_data.get("city") + state = location_data.get("state") + country = location_data.get("country") + country_code = location_data.get("country_code") + + if hasattr(principal, "city"): + principal.city = city or state + if hasattr(principal, "state"): + principal.state = state + if hasattr(principal, "country"): + principal.country = country + if hasattr(principal, "address_line1"): + principal.address_line1 = country_code + + principal.save() diff --git a/manage_events/views.py b/manage_events/views.py index 5952aca..d1899a4 100644 --- a/manage_events/views.py +++ b/manage_events/views.py @@ -1,5 +1,8 @@ from django.shortcuts import get_object_or_404, redirect, render from accounts import resource_action +from goodtimes.services import FacebookAPI, FacebookPoster, InstagramAPI, InstagramPoster, TwitterAPI, TwitterPoster +from goodtimes.utils import JsonResponseUtil +from manage_events.api.serializers import VenueSerializer, VenueShortSerializer from manage_events.forms import ( EventMasterForm, EventCategoryForm, @@ -7,12 +10,14 @@ from manage_events.forms import ( VenueForm, ) from django.core.paginator import Paginator -from .models import EventMaster, Event, EventCategory, EventPrincipalInteraction, Venue +from .models import EventImage, EventMaster, Event, EventCategory, EventPrincipalInteraction, Venue from django.views import generic from django.contrib.auth.mixins import LoginRequiredMixin from django.urls import reverse_lazy from django.contrib import messages from goodtimes import constants +from django.contrib.auth import get_user_model +from datetime import date # Create your views here. @@ -221,7 +226,7 @@ class EventMasterDeleteView(LoginRequiredMixin, generic.View): return redirect(self.success_url) - +from django.core.files.storage import default_storage class EventCreateOrUpdateView(LoginRequiredMixin, generic.View): # Set the page_name and resource page_name = resource_action.RESOURCE_MANAGE_EVENTS @@ -256,6 +261,9 @@ class EventCreateOrUpdateView(LoginRequiredMixin, generic.View): } context.update(kwargs) # Include any additional context data passed to the view return context + + def get_event_images(self): + return [image.image.url for image in EventImage.objects.filter(event=self.object)] def get(self, request, *args, **kwargs): self.object = self.get_object() @@ -265,23 +273,46 @@ class EventCreateOrUpdateView(LoginRequiredMixin, generic.View): self.action = resource_action.ACTION_UPDATE form = self.form_class(instance=self.object) - context = self.get_context_data(form=form) + + context = self.get_context_data(form=form, event_images_urls=self.get_event_images()) return render(request, self.template_name, context=context) def post(self, request, *args, **kwargs): + print(request.POST) + print(request.FILES) self.object = self.get_object() # If an object is found, change action to ACTION_UPDATE if self.object is not None: self.action = resource_action.ACTION_UPDATE - form = self.form_class(request.POST, request.FILES, instance=self.object) + principal_id = request.POST.get('principal') + + form = self.form_class(request.POST, request.FILES, instance=self.object, principal_id=principal_id) if not form.is_valid(): - print(form.errors) - context = self.get_context_data(form=form) + print(f"form error is {form.errors}") + context = self.get_context_data(form=form, event_images_urls=self.get_event_images()) return render(request, self.template_name, context=context) - form.save() + instance = form.save() + instance.created_by = form.cleaned_data.get("principal") + instance.save() + + # Delete old images from storage + old_images = EventImage.objects.filter(event=instance) + for old_image in old_images: + if default_storage.exists(old_image.image.name): + default_storage.delete(old_image.image.name) + + # Delete old images from database + old_images.delete() + + event_images = request.FILES.getlist("event_images") + event_image_objects = [EventImage(event=instance, image=image) for image in event_images] + + EventImage.objects.bulk_create(event_image_objects) + messages.success(self.request, self.get_success_message()) + return redirect(self.success_url) @@ -332,24 +363,26 @@ class EventDetailView(generic.DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["page_name"] = self.page_name - event_id = self.object.id # Get the current event's ID + event = self.object # Get the current event's ID # Separate count for interested and going interested_count = EventPrincipalInteraction.objects.filter( - event_id=event_id, status="interested" + event=event, status="interested" ).count() going_count = EventPrincipalInteraction.objects.filter( - event_id=event_id, status="going" + event=event, status="going" ).count() context["interested_count"] = interested_count context["going_count"] = going_count + today = date.today() + context["publish"] = not event.draft and event.active and event.end_date >= today # Reviews for the event - context["reviews"] = self.object.reviews.all() + context["reviews"] = event.reviews.all() # Images of the event - context["images"] = self.object.event_images.all() + context["images"] = event.event_images.all() return context @@ -413,6 +446,7 @@ class VenueCreateOrUpdateView(LoginRequiredMixin, generic.View): def get(self, request, *args, **kwargs): self.object = self.get_object() + print(f"self.object is {self.object}") # If an object is found, change action to ACTION_UPDATE if self.object is not None: @@ -424,6 +458,7 @@ class VenueCreateOrUpdateView(LoginRequiredMixin, generic.View): def post(self, request, *args, **kwargs): self.object = self.get_object() + print(f"form data is {request.POST} and self.object is {self.object}") # If an object is found, change action to ACTION_UPDATE if self.object is not None: @@ -434,7 +469,10 @@ class VenueCreateOrUpdateView(LoginRequiredMixin, generic.View): print(form.errors) context = self.get_context_data(form=form) return render(request, self.template_name, context=context) - form.save() + + instance = form.save() + instance.created_by = form.cleaned_data.get("principal") + instance.save() messages.success(self.request, self.get_success_message()) return redirect(self.success_url) @@ -477,3 +515,124 @@ class VenueDeleteView(LoginRequiredMixin, generic.View): messages.success(request, self.error_message) return redirect(self.success_url) + + +class CustomerVenueFilterView(LoginRequiredMixin, generic.View): + model = Venue + serializer_class = VenueShortSerializer + + def get(self, request, *args, **kwargs): + pk = request.GET.get("pk", None) + if not pk: + return JsonResponseUtil.error(message="Non transfer user list field is required") + obj = self.model.objects.filter(principal=pk, active=True) + if not obj.exists(): + return JsonResponseUtil.error(message="No venue found for the given user.") + + serializer = self.serializer_class(obj, many=True) + + return JsonResponseUtil.success(message=constants.SUCCESS, data=serializer.data) + + +User = get_user_model() +from .report import generate_event_report, generate_event_report_pdf_three +from django.http import HttpResponse, JsonResponse + + +class GenerateEventReportView(generic.View): + + def get(self, request, user_id): + print("INside GET GenerateEventReportView") + # Generate the event report + report_data = generate_event_report(user_id) + + # Get the user + user = get_object_or_404(User, id=user_id) + + # Generate the PDF + pdf_data, filename = generate_event_report_pdf_three(user, report_data) + + # Create the HttpResponse object with the PDF data + response = HttpResponse(pdf_data, content_type="application/pdf") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + + return response + +class SocialMediaPostView(generic.View): + def get(self, request, *args, **kwargs): + platform = kwargs.get("platform") + event_id = kwargs.get("id") + print(platform, event_id) + errors = [] + success_messages = [] + + try: + event = Event.objects.get(id=event_id) + except Event.DoesNotExist: + errors.append("Event does not exist") + return JsonResponse({ + 'message': "Error in posting to social media", + 'errors': errors, + 'success_messages': success_messages + }, status=400) + + if not event.active: + errors.append("Event is not active") + return JsonResponse({ + 'message': "Error in posting to social media", + 'errors': errors, + 'success_messages': success_messages + }, status=400) + + caption = f"Venue: {event.venue.title} \n Event: {event.title}\n Description: {event.description} \n Date: {event.start_date} to {event.end_date}\n Time: {event.from_time} - {event.to_time} \n Address: {event.venue.address}" + + if platform in ['instagram', 'facebook', 'twitter', 'all']: + if platform in ['twitter', 'all']: + image_url = event.image.path + twitter_api = TwitterAPI() + twitter_poster = TwitterPoster(twitter_api) + result = twitter_poster.post_image_with_caption(image_url, caption) + if result['success']: + success_messages.append("Posted to Twitter successfully") + else: + errors.append("Fail to post on Twitter") + + image_url = request.build_absolute_uri(event.image.url) + if platform in ['facebook', 'all']: + try: + print("facebook is called") + facebook_api = FacebookAPI() + facebook_poster = FacebookPoster(facebook_api) + result = facebook_poster.post_photo(image_url, caption) + if result["success"]: + success_messages.append("Posted to Facebook successfully") + else: + errors.append("Fail to post on Facebook") + except Exception as e: + print(f"facebook error {e}") + errors.append("Fail to post on Facebook") + + if platform in ['instagram', 'all']: + instagram_api = InstagramAPI() + instagram_poster = InstagramPoster(instagram_api) + result = instagram_poster.post_image_with_caption(image_url, caption) + if result["success"]: + success_messages.append("Posted to Instagram successfully") + else: + errors.append("Fail to post on Instagram") + + if not errors: + return JsonResponse({'message': 'Post Successful', 'errors': errors, 'success_messages': success_messages}) + + if errors and success_messages: + return JsonResponse({ + 'message': 'Some posts succeeded while others failed', + 'errors': errors, + 'success_messages': success_messages + }, status=200) + + return JsonResponse({ + 'message': 'Error in posting to social media', + 'errors': errors, + 'success_messages': success_messages + }, status=400) \ No newline at end of file diff --git a/manage_notifications/management/commands/interested_going.py b/manage_notifications/management/commands/interested_going.py index ce33b70..bddc788 100644 --- a/manage_notifications/management/commands/interested_going.py +++ b/manage_notifications/management/commands/interested_going.py @@ -29,21 +29,7 @@ class Command(BaseCommand): principal_interaction.event.title ) # Accessing event title correctly message = f"{event_title} is going live tomorrow." - - try: - player_id = ( - principal_interaction.principal.player_id - ) # Correctly access player_id from principal - except AttributeError: - continue - notification_title = "Event Reminder" - notification_payload = { - "headings": {"en": notification_title}, - "contents": {"en": message}, - "include_player_ids": [player_id], - } - response = client.send_notification(notification_payload) in_app_notification = InAppNotification( principal=principal_interaction.principal, @@ -53,6 +39,28 @@ class Command(BaseCommand): ) in_app_notification.save() + notification_settings = IAmPrincipalNotificationSettings.objects.filter( + principal=principal_interaction.principal, + notification_category=NotificationCategoryChoices.EVENT, + is_enabled=True, + ).exists() + + if notification_settings: + try: + player_id = ( + principal_interaction.principal.player_id + ) # Correctly access player_id from principal + if player_id: + notification_payload = { + "headings": {"en": notification_title}, + "contents": {"en": message}, + "include_player_ids": [player_id], + } + response = client.send_notification(notification_payload) + except AttributeError: + # Handle the case where player_id does not exist + continue + self.stdout.write(self.style.SUCCESS("Event reminders sent successfully")) def eligible_event_interactions(self): @@ -62,8 +70,8 @@ class Command(BaseCommand): event__deleted=False, event__active=True, event__created_by__is_active=True, - event__created_by__deleted=False, principal__is_active=True, - principal__deleted=False, status__in=[EventInteractionType.GOING, EventInteractionType.INTERESTED], + # principal__notifications_principal__notification_category=NotificationCategoryChoices.EVENT, + # principal__notifications_principal__is_enabled=True, ).select_related("principal", "event") diff --git a/manage_notifications/management/commands/one_week_alert.py b/manage_notifications/management/commands/one_week_alert.py index 5740382..4dfbb9d 100644 --- a/manage_notifications/management/commands/one_week_alert.py +++ b/manage_notifications/management/commands/one_week_alert.py @@ -75,26 +75,30 @@ class Command(BaseCommand): message = f"Your subscription is going to expire in {days_before} days." for principal in eligible_principals: - try: - player_id = principal.principal.player_id - except AttributeError: - continue - notification_title = "Subscription Expiry Reminder" - notification_payload = { - "headings": {"en": notification_title}, - "contents": {"en": message}, - "include_player_ids": [player_id], - } - response = client.send_notification(notification_payload) - in_app_notification = InAppNotification( principal=principal.principal, title=notification_title, message=message, notification_category=NotificationCategoryChoices.SUBSCRIPTION, ) + + if principal.is_enabled: + try: + player_id = principal.principal.player_id + except AttributeError: + continue + + notification_payload = { + "headings": {"en": notification_title}, + "contents": {"en": message}, + "include_player_ids": [player_id], + } + response = client.send_notification(notification_payload) + + # Save the notification in the database in_app_notification.save() + self.stdout.write( self.style.SUCCESS( f"Notifications for {days_before} days sent successfully" @@ -106,8 +110,7 @@ class Command(BaseCommand): return IAmPrincipalNotificationSettings.objects.filter( principal__principal_subscription__end_date=target_date, principal__principal_subscription__status=SubscriptionStatus.ACTIVE, - principal__principal_subscription__cancelled=False, principal__principal_subscription__deleted=False, notification_category=NotificationCategoryChoices.SUBSCRIPTION, - is_enabled=True, + # is_enabled=True, ).select_related("principal") diff --git a/manage_referrals/api/serializers.py b/manage_referrals/api/serializers.py index 48dfa83..53c559a 100644 --- a/manage_referrals/api/serializers.py +++ b/manage_referrals/api/serializers.py @@ -50,4 +50,5 @@ class ReferralRecordRewardSerializer(serializers.ModelSerializer): "coins", "unique_token", "value", + "created_on", ] diff --git a/manage_referrals/api/urls.py b/manage_referrals/api/urls.py index 6d2f4c0..569815c 100644 --- a/manage_referrals/api/urls.py +++ b/manage_referrals/api/urls.py @@ -24,4 +24,9 @@ urlpatterns = [ views.RedeemRewardView.as_view(), name="redeem_reward", ), + path( + "redeem-selected-rewards/", + views.RedeemSelectedRewardsView.as_view(), + name="redeem_selected_rewards", + ), ] diff --git a/manage_referrals/api/views.py b/manage_referrals/api/views.py index c748556..f131bea 100644 --- a/manage_referrals/api/views.py +++ b/manage_referrals/api/views.py @@ -62,7 +62,9 @@ class RewardListView(APIView): def get(self, request): # Filter rewards based on specified conditions - current_principal = request.user # Adjust based on how user is linked to principal + current_principal = ( + request.user + ) # Adjust based on how user is linked to principal # Filter rewards based on the authenticated referrer rewards_query = ReferralRecordReward.objects.filter( @@ -126,3 +128,68 @@ class RedeemRewardView(APIView): message=constants.SUCCESS, data="Token sold successfully.", ) + + +class RedeemSelectedRewardsView(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def patch(self, request): + # Extract the number of tokens from the request data + num_tokens_to_sell = request.data.get("num_tokens", None) + + if num_tokens_to_sell is None: + return ApiResponse.error( + status=status.HTTP_400_BAD_REQUEST, + message=constants.FAILURE, + errors="Number of tokens to sell is required.", + ) + + try: + num_tokens_to_sell = int(num_tokens_to_sell) + except ValueError: + return ApiResponse.error( + status=status.HTTP_400_BAD_REQUEST, + message=constants.FAILURE, + errors="Invalid number of tokens.", + ) + + # Retrieve the rewards for the authenticated user + rewards = ReferralRecordReward.objects.filter( + referral_record__referrer_principal=request.user, sell=False + ).order_by( + "id" + ) # FIFO method + + if rewards.count() < num_tokens_to_sell: + return ApiResponse.error( + status=status.HTTP_404_NOT_FOUND, + message=constants.FAILURE, + errors="Not enough unsold rewards available.", + ) + + # Select the required number of rewards + rewards_to_sell = rewards[:num_tokens_to_sell] + total_value = sum(reward.value for reward in rewards_to_sell) + total_coins = sum(reward.coins for reward in rewards_to_sell) + tokens = ",".join(str(reward.unique_token) for reward in rewards_to_sell) + + with transaction.atomic(): + # Create a new withdrawal request + withdrawal_request = WithdrawalRequest.objects.create( + principal=request.user, + coins=total_coins, + amount=total_value, + token=tokens, + ) + + # Update each reward to mark it as sold + for reward in rewards_to_sell: + reward.sell = True + reward.save() + + return ApiResponse.success( + status=status.HTTP_200_OK, + message=constants.SUCCESS, + data="Selected tokens sold successfully.", + ) diff --git a/manage_subscriptions/admin.py b/manage_subscriptions/admin.py index d0c6816..c3fe4ca 100644 --- a/manage_subscriptions/admin.py +++ b/manage_subscriptions/admin.py @@ -1,31 +1,19 @@ from django.contrib import admin from .models import ( - Plan, PrincipalSubscription, Subscription, WebhookEvent, ) # Update this with the correct import path for your models -# Plan ModelAdmin -class PlanAdmin(admin.ModelAdmin): - list_display = ("id", "title", "days") # Include 'id' field here - search_fields = ("title",) # Add search functionality by title - - -# Register Plan with the admin site -admin.site.register(Plan, PlanAdmin) - - # Subscription ModelAdmin class SubscriptionAdmin(admin.ModelAdmin): - list_display = ("id", "title", "plan", "amount") # Include 'id' field here - list_select_related = ("plan",) # Optimizes queries for the plan field + list_display = ("id", "title", "interval", "amount") # Include 'id' field here + list_select_related = ("interval",) # Optimizes queries for the interval field search_fields = ( "title", - "plan__title", - ) # Add search functionality by title and plan's title - raw_id_fields = ("plan",) # Use a raw ID widget for the plan ForeignKey field + "interval", + ) # Add search functionality by title and interval's title # Register Subscription with the admin site @@ -47,7 +35,7 @@ class PrincipalSubscriptionAdmin(admin.ModelAdmin): "is_paid", "auto_renew", "status", - "cancelled", + # "cancelled", ) # Enable filtering by these fields search_fields = ( "subscription__title", @@ -63,7 +51,6 @@ class PrincipalSubscriptionAdmin(admin.ModelAdmin): admin.site.register(PrincipalSubscription, PrincipalSubscriptionAdmin) - @admin.register(WebhookEvent) class WebhookEventAdmin(admin.ModelAdmin): list_display = ("event_id", "received_at", "event_type", "processed_at", "status") diff --git a/manage_subscriptions/api/urls.py b/manage_subscriptions/api/urls.py index fb63bae..22e94f0 100644 --- a/manage_subscriptions/api/urls.py +++ b/manage_subscriptions/api/urls.py @@ -1,8 +1,5 @@ from django.urls import path from . import views -from rest_framework_simplejwt.views import ( - TokenRefreshView, -) urlpatterns = [ diff --git a/manage_subscriptions/api/views.py b/manage_subscriptions/api/views.py index 05cd2e6..56deb6e 100644 --- a/manage_subscriptions/api/views.py +++ b/manage_subscriptions/api/views.py @@ -1,5 +1,6 @@ from datetime import timedelta import datetime +import json from django.db import transaction from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -7,9 +8,7 @@ from rest_framework import status from rest_framework.views import APIView from django.conf import settings import stripe -from accounts.models import IAmPrincipal -import json -from goodtimes import constants, services +from goodtimes import constants from manage_subscriptions.models import ( Subscription, PrincipalSubscription, @@ -35,7 +34,11 @@ from .serializers import PrincipalSubscriptionSerializer from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator from rest_framework.response import Response -from goodtimes.webhook import PaymentProcessingService +from goodtimes.webhook.payment_processing_service import PaymentProcessingService +import logging + + +logger = logging.getLogger(__name__) class CreatePrincipalSubscriptionApi(APIView): @@ -118,48 +121,6 @@ class CreatePrincipalSubscriptionApi(APIView): return ApiResponse.error(**fail_response) -# class CreatePrincipalSubscriptionApi(APIView): -# authentication_classes = [JWTAuthentication] -# permission_classes = [IsAuthenticated] - -# def post(self, request): -# serializer = PrincipalSubscriptionSerializer(data=request.data) - -# if serializer.is_valid(): -# subscription_id = serializer.validated_data.get("subscription").id -# try: -# subscription = Subscription.objects.get(id=subscription_id) -# except Subscription.DoesNotExist: -# return ApiResponse.error( -# status=status.HTTP_404_NOT_FOUND, message="Subscription not found." -# ) - -# start_date = timezone.localtime().date() -# end_date = start_date + timedelta(days=subscription.plan.days) -# grace_period_end_date = end_date + timedelta(days=15) - -# # You can directly pass the additional fields as save method arguments -# instance = serializer.save( -# start_date=start_date, -# end_date=end_date, -# grace_period_end_date=grace_period_end_date, -# created_by=request.user, # Assuming your model has this field and you want to track who created the subscription -# ) - -# success_response = { -# "status": status.HTTP_201_CREATED, # Use 201 for successful resource creation -# "message": "Success", -# "data": serializer.data, -# } -# return ApiResponse.success(**success_response) - -# else: -# fail_response = { -# "status": status.HTTP_400_BAD_REQUEST, -# "message": "Validation Failed", -# "errors": serializer.errors, -# } -# return ApiResponse.error(**fail_response) @method_decorator(csrf_exempt, name="dispatch") @@ -172,83 +133,115 @@ class StripeWebhookTest(APIView): stripe.api_key = settings.STRIPE_SECRET_KEY payload = request.body sig_header = request.META["HTTP_STRIPE_SIGNATURE"] - endpoint_secret = "whsec_ccf1f87295603cdd1733995ee2d3c0d6f74c7ceaf28916ea45114a54b7ce1d0f" # Make sure to retrieve this from your settings + # This is your Stripe CLI webhook secret for testing your endpoint locally. + endpoint_secret = settings.ENDPOINT_SECRET event = None + webhook_event = None + try: + # Construct Stripe event event = stripe.Event.construct_from(json.loads(payload), stripe.api_key) event_id = event["id"] event_type = event["type"] - principal_id = event["data"]["object"]["metadata"]["principal"] + stripe_subscription_id = event["data"]["object"].get("subscription") + # Retrieve subscription details if available + stripe_subscription = ( + stripe.Subscription.retrieve(stripe_subscription_id) + if stripe_subscription_id + else None + ) + + current_period_start = stripe_subscription["current_period_start"] if stripe_subscription else None + current_period_end = stripe_subscription["current_period_end"] if stripe_subscription else None + + # Log received event details + logger.info(f"Received event {event_type} with ID {event_id}") + + # Get or create WebhookEvent in DB webhook_event, created = WebhookEvent.objects.get_or_create( event_id=event_id, defaults={ "event_type": event_type, - "event_payload": json.loads(payload), + "event_payload": event, }, ) if not created and webhook_event.status == "processed": + logger.info(f"Event {event_id} already processed.") return ApiResponse.success( status=status.HTTP_208_ALREADY_REPORTED, - message="Event already processed", + message="Event already processed.", ) - # Check if there is an active principal subscription - if self._has_active_principal_subscription(principal_id): - return ApiResponse.success( - status=status.HTTP_208_ALREADY_REPORTED, - message="Active principal subscription already exists", - ) - - # payment_service = services.PaymentProcessingService(webhook_data=event) - payment_service = PaymentProcessingService(webhook_data=event) + # Process the event + payment_service = PaymentProcessingService( + webhook_data=event, + stripe_subscription=stripe_subscription_id, + current_period_start=current_period_start, + current_period_end=current_period_end, + ) payment_service.process_event() - webhook_event = WebhookEvent.objects.get(event_id=event_id) + + # Mark event as successfully processed webhook_event.status = "processed" - webhook_event.processed_at = timezone.now() # Make sure to import timezone + webhook_event.processed_at = timezone.now() webhook_event.save() + + logger.info(f"Event {event_id} processed successfully.") return ApiResponse.success( - status=status.HTTP_200_OK, message="Event processed successfully" + status=status.HTTP_200_OK, message="Event processed successfully." + ) + + except stripe.error.SignatureVerificationError as e: + logger.error(f"Invalid Stripe signature for event: {str(e)}") + return ApiResponse.error( + status=status.HTTP_400_BAD_REQUEST, + message="Invalid signature.", + errors=str(e), ) except ValueError as e: - # Invalid payload + logger.error(f"Invalid payload for event: {str(e)}") return ApiResponse.error( status=status.HTTP_400_BAD_REQUEST, - message="Invalid payload", - errors=str(e), - ) - except stripe.error.SignatureVerificationError as e: - # Invalid signature - return ApiResponse.error( - status=status.HTTP_400_BAD_REQUEST, - message="Invalid signature", - errors=str(e), - ) - except Transaction.DoesNotExist: - # Handle case where the transaction does not exist - return ApiResponse.error( - status=status.HTTP_404_NOT_FOUND, message="Transaction not found" - ) - except Exception as e: - webhook_event.status = "failed" - webhook_event.error_message = str(e) - webhook_event.save() - return ApiResponse.error( - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - message="Error processing event", + message="Invalid payload.", errors=str(e), ) - def _has_active_principal_subscription(self, principal_id): - return PrincipalSubscription.objects.filter( - principal__id=principal_id, - active=True, - deleted=False, - is_paid=True, - end_date__gte=timezone.now().date(), - ).exists() + except stripe.error.InvalidRequestError as e: + logger.error(f"Invalid request for event: {str(e)}") + return ApiResponse.error( + status=status.HTTP_400_BAD_REQUEST, + message="Invalid request to Stripe.", + errors=str(e), + ) + + except stripe.error.StripeError as e: + logger.error(f"General Stripe error: {str(e)}") + return ApiResponse.error( + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + message="Stripe error occurred.", + errors=str(e), + ) + + except Exception as e: + logger.error(f"Unexpected error processing event {event_id}: {str(e)}") + if "webhook_event" in locals(): + webhook_event.status = "failed" + webhook_event.error_message = str(e) + webhook_event.save() + + return ApiResponse.error( + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + message="Unexpected error processing event.", + errors=str(e), + ) + finally: + print(f"finally is runn") + webhook_event.status = "processed" + webhook_event.processed_at = timezone.now() + webhook_event.save() class LastActiveSubscriptionView(APIView): @@ -281,3 +274,47 @@ class LastActiveSubscriptionView(APIView): message="No Active Subscription Found", errors="No Active Subscription Found", ) + + +class CancelSubscription(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request): + data = json.loads(request.body) + subscription_id = data.get("subscription_id") + + try: + subscription = PrincipalSubscription.objects.get( + id=subscription_id, principal=request.user + ) + except PrincipalSubscription.DoesNotExist: + return ApiResponse( + message=constants.FAILURE, + errors="Subscription not found.", + status=status.HTTP_404_NOT_FOUND, + ) + + with transaction.atomic(): + if subscription.stripe_subscription_id: + # Cancel Stripe subscription + try: + stripe.Subscription.modify(subscription.stripe_subscription_id, cancel_at_period_end=True) + except stripe.error.InvalidRequestError as e: + return ApiResponse( + message=constants.FAILURE, + errors=f"Stripe error: {str(e)}", + status=status.HTTP_400_BAD_REQUEST, + ) + + # Updating subscription status in the local database + subscription.status = SubscriptionStatus.INACTIVE + # subscription.cancelled = True + subscription.cancelled_date_time = timezone.now() + subscription.save() + + return ApiResponse( + message=constants.SUCCESS, + data="Subscription cancelled successfully.", + status=status.HTTP_200_OK, + ) diff --git a/manage_subscriptions/forms.py b/manage_subscriptions/forms.py index 3579fc4..05dc2fc 100644 --- a/manage_subscriptions/forms.py +++ b/manage_subscriptions/forms.py @@ -1,43 +1,64 @@ from django import forms -from manage_subscriptions.models import PrincipalSubscription, Subscription, Plan - - -class PlanForm(forms.ModelForm): - class Meta: - model = Plan - fields = ["title", "days"] # Include all fields you want from the model - - # You can add custom validation for Plan fields here if needed - # Example: - # def clean_title(self): - # title = self.cleaned_data.get('title') - # # Add your validation logic here - # return title - +from accounts.models import IAmPrincipalType +from manage_subscriptions.models import ( + PrincipalSubscription, + Subscription, +) class SubscriptionForm(forms.ModelForm): class Meta: model = Subscription fields = [ "title", - "plan", + "short_description", + "long_description", + "interval", + "interval_count", "high_amount", "amount", - "short_description", - # "long_description", - # "image", "principal_types", "referral_percentage", - ] # Include all fields you want from the model + "active", + "is_free", + ] + def __init__(self, *args, **kwargs): + super(SubscriptionForm, self).__init__(*args, **kwargs) + event_user = IAmPrincipalType.objects.get(name="event_user") + event_manager = IAmPrincipalType.objects.get(name="event_manager") + self.fields["principal_types"].queryset = IAmPrincipalType.objects.filter( + id__in=[event_user.id, event_manager.id] + ) + +class SubscriptionUpdateForm(forms.ModelForm): + class Meta: + model = Subscription + fields = [ + "title", + "short_description", + "long_description", + "referral_percentage", + "active", + "is_free", + ] class PrincipalSubscriptionForm(forms.ModelForm): class Meta: model = PrincipalSubscription - fields = "__all__" # Includes all fields from the model + fields = [ + "subscription", + "principal", + "status", + "start_date", + "end_date", + "grace_period_end_date", + "comments", + "coupon_code" + ] # Includes all fields from the model widgets = { "start_date": forms.DateInput(attrs={"type": "date"}), "end_date": forms.DateInput(attrs={"type": "date"}), "grace_period_end_date": forms.DateInput(attrs={"type": "date"}), - "cancelled_date_time": forms.DateTimeInput(attrs={"type": "datetime"}), } + + diff --git a/manage_subscriptions/migrations/0008_subscription_is_free.py b/manage_subscriptions/migrations/0008_subscription_is_free.py new file mode 100644 index 0000000..d86bd47 --- /dev/null +++ b/manage_subscriptions/migrations/0008_subscription_is_free.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-06-25 07:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_subscriptions', '0007_alter_subscription_referral_percentage'), + ] + + operations = [ + migrations.AddField( + model_name='subscription', + name='is_free', + field=models.BooleanField(default=False, help_text='Indicates whether this subscription is free and only accessible by administrators, not visible to regular users.'), + ), + ] diff --git a/manage_subscriptions/migrations/0009_principalsubscription_coupon_code.py b/manage_subscriptions/migrations/0009_principalsubscription_coupon_code.py new file mode 100644 index 0000000..b693abb --- /dev/null +++ b/manage_subscriptions/migrations/0009_principalsubscription_coupon_code.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-07-22 12:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("manage_subscriptions", "0008_subscription_is_free"), + ] + + operations = [ + migrations.AddField( + model_name="principalsubscription", + name="coupon_code", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/manage_subscriptions/migrations/0010_principalsubscription_comments_and_more.py b/manage_subscriptions/migrations/0010_principalsubscription_comments_and_more.py new file mode 100644 index 0000000..20544a5 --- /dev/null +++ b/manage_subscriptions/migrations/0010_principalsubscription_comments_and_more.py @@ -0,0 +1,97 @@ +# Generated by Django 5.0.2 on 2024-07-31 07:34 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("manage_subscriptions", "0009_principalsubscription_coupon_code"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="principalsubscription", + name="comments", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="principalsubscription", + name="is_stripe_subscription", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="principalsubscription", + name="stripe_subscription_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="subscription", + name="price_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.CreateModel( + name="StripeProduct", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("product_id", models.CharField(blank=True, max_length=255, null=True)), + ("description", models.TextField(blank=True, null=True)), + ("metadata", models.JSONField(blank=True, null=True)), + ("image_url", models.URLField(blank=True, null=True)), + ( + "default_price_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "stripe_product", + }, + ), + migrations.AddField( + model_name="subscription", + name="stripe_product", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="subscription_product", + to="manage_subscriptions.stripeproduct", + ), + ), + ] diff --git a/manage_subscriptions/migrations/0011_subscription_product_id.py b/manage_subscriptions/migrations/0011_subscription_product_id.py new file mode 100644 index 0000000..5a478b1 --- /dev/null +++ b/manage_subscriptions/migrations/0011_subscription_product_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-08-18 18:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_subscriptions', '0010_principalsubscription_comments_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='subscription', + name='product_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/manage_subscriptions/migrations/0012_subscription_interval_subscription_interval_count.py b/manage_subscriptions/migrations/0012_subscription_interval_subscription_interval_count.py new file mode 100644 index 0000000..2a9979b --- /dev/null +++ b/manage_subscriptions/migrations/0012_subscription_interval_subscription_interval_count.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.2 on 2024-08-19 09:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_subscriptions', '0011_subscription_product_id'), + ] + + operations = [ + migrations.AddField( + model_name='subscription', + name='interval', + field=models.CharField(choices=[('month', 'month'), ('day', 'day'), ('week', 'week'), ('year', 'year')], default='month', max_length=10), + preserve_default=False, + ), + migrations.AddField( + model_name='subscription', + name='interval_count', + field=models.IntegerField(default=1), + ), + ] diff --git a/manage_subscriptions/migrations/0013_remove_subscription_plan_and_more.py b/manage_subscriptions/migrations/0013_remove_subscription_plan_and_more.py new file mode 100644 index 0000000..be3ba14 --- /dev/null +++ b/manage_subscriptions/migrations/0013_remove_subscription_plan_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.0.2 on 2024-08-21 18:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_subscriptions', '0012_subscription_interval_subscription_interval_count'), + ] + + operations = [ + migrations.RemoveField( + model_name='subscription', + name='plan', + ), + migrations.RemoveField( + model_name='stripeproduct', + name='created_by', + ), + migrations.RemoveField( + model_name='stripeproduct', + name='modified_by', + ), + migrations.RemoveField( + model_name='subscription', + name='stripe_product', + ), + migrations.RemoveField( + model_name='principalsubscription', + name='cancelled', + ), + migrations.RemoveField( + model_name='principalsubscription', + name='is_stripe_subscription', + ), + migrations.DeleteModel( + name='Plan', + ), + migrations.DeleteModel( + name='StripeProduct', + ), + ] diff --git a/manage_subscriptions/migrations/0014_alter_subscription_long_description.py b/manage_subscriptions/migrations/0014_alter_subscription_long_description.py new file mode 100644 index 0000000..d117a17 --- /dev/null +++ b/manage_subscriptions/migrations/0014_alter_subscription_long_description.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.2 on 2024-08-25 10:39 + +import django_quill.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_subscriptions', '0013_remove_subscription_plan_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='subscription', + name='long_description', + field=django_quill.fields.QuillField(default=''), + ), + ] diff --git a/manage_subscriptions/migrations/0015_alter_subscription_long_description.py b/manage_subscriptions/migrations/0015_alter_subscription_long_description.py new file mode 100644 index 0000000..417f2d0 --- /dev/null +++ b/manage_subscriptions/migrations/0015_alter_subscription_long_description.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.2 on 2024-12-20 09:29 + +import django_quill.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_subscriptions', '0014_alter_subscription_long_description'), + ] + + operations = [ + migrations.AlterField( + model_name='subscription', + name='long_description', + field=django_quill.fields.QuillField(), + ), + ] diff --git a/manage_subscriptions/models.py b/manage_subscriptions/models.py index ae99b31..a15766d 100644 --- a/manage_subscriptions/models.py +++ b/manage_subscriptions/models.py @@ -1,35 +1,42 @@ +from datetime import timedelta +from django.utils import timezone from django.db import models +from django.core.exceptions import ValidationError from accounts.models import BaseModel, IAmPrincipal, IAmPrincipalType from django.utils.translation import gettext_lazy as _ - -# Create your models here. - - -class Plan(BaseModel): - title = models.CharField(max_length=255) - days = models.PositiveIntegerField() - - class Meta: - db_table = "plan" - - def __str__(self): - return self.title +from django_quill.fields import QuillField class Subscription(BaseModel): + MONTH = "month" + DAY = "day" + WEEK = "week" + YEAR = "year" + + INTERVAL_TYPES = [ + (MONTH, "month"), + (DAY, "day"), + (WEEK, "week"), + (YEAR, "year"), + ] title = models.CharField(max_length=255) + price_id = models.CharField(max_length=255, blank=True, null=True) + product_id = models.CharField(max_length=255, blank=True, null=True) short_description = models.CharField(max_length=255, null=True, blank=True) - long_description = models.TextField(null=True, blank=True) + long_description = QuillField() image = models.ImageField(upload_to="subscription_img", null=True, blank=True) - plan = models.ForeignKey( - Plan, related_name="subscription_plan", on_delete=models.CASCADE - ) + interval = models.CharField(max_length=10, choices=INTERVAL_TYPES) + interval_count = models.IntegerField(default=1) high_amount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00) amount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00) principal_types = models.ManyToManyField( IAmPrincipalType, related_name="principal_type_subscriptions", blank=True ) referral_percentage = models.DecimalField(max_digits=5, decimal_places=2) + is_free = models.BooleanField( + default=False, + help_text="Indicates whether this subscription is free and only accessible by administrators, not visible to regular users.", + ) class Meta: db_table = "subscription" @@ -37,6 +44,90 @@ class Subscription(BaseModel): def __str__(self): return self.title + def clean(self): + # Ensure amount is greater than 1 + if self.amount <= 1: + raise ValidationError({"amount": "Amount must be greater than 1."}) + + # Ensure high_amount is greater than amount + if self.high_amount <= self.amount: + raise ValidationError( + {"high_amount": "High amount must be greater than amount."} + ) + + def save(self, *args, **kwargs): + from goodtimes.services import StripeService + + if self.pk and self.deleted: + return super().save(*args, **kwargs) + self.clean() + + if self.is_free: + # If is_free is True, set amounts to 0 and remove Stripe price and product IDs + self.high_amount = 0.00 + self.amount = 0.00 + self.price_id = None + self.product_id = None + else: + if self.pk and self.price_id: # Update existing subscription + # Retrieve existing price and product from Stripe + price = StripeService.retrieve_price(self.price_id) + if not price["success"]: + raise Exception(price['message']) + + # Update price active status if it differs from local active status + if self.active != price["data"].active: + StripeService.update_price(price_id=self.price_id, active=self.active) + + # Retrieve existing product from Stripe + product = StripeService.retrive_product(self.product_id) + if not product["success"]: + raise Exception(product['message']) + + # Update product data if it has changed + if product["data"].name != self.title or product["data"].description != self.short_description: + StripeService.update_product( + product_id=self.product_id, + name=self.title, + description=self.short_description + ) + else: + print("new pricde create is clled =========================================================") + # Create new product and price + price = StripeService.create_price( + product_data={ + "name": self.title, + "description": self.short_description, + }, + unit_amount=int(self.amount * 100), + currency="gbp", + recurring={ + "interval": self.interval, + "interval_count": self.interval_count, + }, + metadata={ + "subscription_id": self.id + } + + ) + if not price["success"]: + raise Exception(price['message']) + + # Add the IDs to the record + self.price_id = price["data"].id + self.product_id = price["data"].product + + super().save(*args, **kwargs) + + def calculate_days(self): + count = { + self.DAY: 1, + self.MONTH: 30, # assuming a month is 30 days + self.YEAR: 365, + self.WEEK: 7 + } + return count[self.interval] * self.interval_count + class SubscriptionStatus(models.TextChoices): ACTIVE = "active", _("Active") @@ -61,20 +152,78 @@ class PrincipalSubscription(BaseModel): start_date = models.DateField() end_date = models.DateField() order_id = models.CharField(max_length=255, null=True, blank=True) - cancelled = models.BooleanField(default=False) cancelled_date_time = models.DateTimeField(null=True, blank=True) grace_period_end_date = models.DateField(null=True, blank=True) stripe_customer_id = models.CharField(max_length=255, null=True, blank=True) + stripe_subscription_id = models.CharField(max_length=255, null=True, blank=True) + comments = models.CharField(max_length=255, null=True, blank=True) payment_intent_id = models.CharField(max_length=255, null=True, blank=True) payment_intent_client_secret = models.CharField( max_length=255, null=True, blank=True ) + coupon_code = models.CharField(max_length=255, null=True, blank=True) class Meta: db_table = "principal_subscription" def __str__(self): return f"{self.subscription} - {self.principal.first_name}" + + def save(self, *args, **kwargs): + # If the subscription status is expired or inactive, set the active flag to False + if self.status in [SubscriptionStatus.EXPIRED, SubscriptionStatus.INACTIVE]: + self.active = False + + # If the active flag is False, set the status to inactive + if not self.active: + self.status = SubscriptionStatus.INACTIVE + super().save(*args, **kwargs) + + + def generate_order_id(email): + return f"order_{str(timezone.localtime().timestamp())}{str(email)}" + + def generate_grace_period_end_date(date): + return date + timedelta(days=15) + + @classmethod + def has_principal_subscription(cls, principal): + return cls.get_grace_period_princial_subscription(principal).exists() + + @classmethod + def get_grace_period_princial_subscription(cls, principal): + return cls.objects.filter( + principal=principal, + is_paid=True, + active=True, + status=SubscriptionStatus.ACTIVE, + grace_period_end_date__gt=timezone.now().date(), + ) + + @classmethod + def get_active_princial_subscription(cls, principal): + return cls.objects.filter( + principal=principal, + is_paid=True, + active=True, + status=SubscriptionStatus.ACTIVE, + end_date__gt=timezone.now().date(), + ).order_by('-end_date').last() + + @classmethod + def get_principal_subscription(cls, principal): + return cls.objects.filter( + principal=principal, + is_paid=True, + active=True, + status=SubscriptionStatus.ACTIVE, + ).order_by("-grace_period_end_date").first() + + @classmethod + def cancel_stipe_auto_renew_subscription(cls, subscription): + subscription.auto_renew = False + subscription.cancelled_date_time = timezone.now() + subscription.save() class WebhookEvent(BaseModel): diff --git a/manage_subscriptions/urls.py b/manage_subscriptions/urls.py index aa767f8..21c0d15 100644 --- a/manage_subscriptions/urls.py +++ b/manage_subscriptions/urls.py @@ -17,61 +17,51 @@ urlpatterns = [ views.SubscriptionCreateOrUpdateView.as_view(), name="subscription_edit", ), - # path( - # "subscription/delete/", - # views.SubscriptionDeleteView.as_view(), - # name="subscription_delete", - # ), - # PLANS - path("plan/list/", views.PlanView.as_view(), name="plan_list"), - # path( - # "plan/add/", - # views.PlanCreateOrUpdateView.as_view(), - # name="plan_add", - # ), - # path( - # "plan/edit//", - # views.PlanCreateOrUpdateView.as_view(), - # name="plan_edit", - # ), - # path( - # "plan/delete/", - # views.PlanDeleteView.as_view(), - # name="plan_delete", - # ), + path("subscription//", views.SubscriptionDetailView.as_view(), name="subscription_detail"), + path( + "subscription/delete/", + views.SubscriptionDeleteView.as_view(), + name="subscription_delete", + ), + # Principal Subscription path( "principal_subscription/list/", views.PrincipalSubscriptionView.as_view(), name="principal_subscriptions_list", ), - # path( - # "principal_subscription/add/", - # views.PrincipalSubscriptionCreateOrUpdateView.as_view(), - # name="principal_subscription_add", - # ), path( "principal_subscription/edit//", views.PrincipalSubscriptionCreateOrUpdateView.as_view(), name="principal_subscription_edit", ), + path( + "principal_subscription/", + views.PrincipalSubscriptionDetailView.as_view(), + name="principal_subscription_detail", + ), path( "principal_subscription/delete/", views.PrincipalSubscriptionDeleteView.as_view(), name="principal_subscription_delete", ), - path( - "stripe-subscription/", - views.stripe_config, - name="stripe_subscription", - ), path( "create-checkout-session/", views.create_checkout_session, name="create_checkout_session", ), + path( + "coupon-validity-check/", + views.validate_coupon, + name="validate_coupon", + ), path("stripe/", views.SubscriptionPageView.as_view(), name="stripe"), + path("active/", views.ActiveSubscriptionView.as_view(), name="active"), + path("cancel-subscription/", views.CancelAutoSubscriptionView.as_view(), name="cancel_subscription"), + path("404/", views.ErrorView.as_view(), name="error"), path("success/", views.SuccessView.as_view(), name="success"), path("cancel/", views.CancelView.as_view(), name="cancel"), + path("subscription-cancel-success/", views.SubscriptionCancelSuccessView.as_view(), name="subscription_cancel_success"), + path("subscription-cancel-fails/", views.SubscriptionCancelFailsView.as_view(), name="subscription_cancel_fails"), # path("join-now/", views.IndexView.as_view(), name="index"), ] diff --git a/manage_subscriptions/utils.py b/manage_subscriptions/utils.py index f69e4b6..a7dbd83 100644 --- a/manage_subscriptions/utils.py +++ b/manage_subscriptions/utils.py @@ -8,26 +8,6 @@ API_KEY = settings.GOOGLE_MAPS_API_KEY gmaps = googlemaps.Client(key=API_KEY) -def get_active_subscription_id_for_principal(principal): - # Filter subscriptions for the principal that are active and not cancelled - active_subscriptions = PrincipalSubscription.objects.filter( - principal=principal, - status=SubscriptionStatus.ACTIVE, - is_paid=True, - cancelled=False, - deleted=False, - active=True, - end_date__gte=now().date(), # Ensure the subscription hasn't expired - ).order_by( - "-end_date" - ) # Order by end_date to get the most recent active subscription - - if active_subscriptions.exists(): - # Return the ID of the most recent active subscription - return active_subscriptions.first().id - return None - - def get_location_info(latitude, longitude): reverse_geocode_result = gmaps.reverse_geocode((latitude, longitude)) diff --git a/manage_subscriptions/views.py b/manage_subscriptions/views.py index dbbdcec..e2af70e 100644 --- a/manage_subscriptions/views.py +++ b/manage_subscriptions/views.py @@ -1,20 +1,20 @@ -from datetime import timedelta +from decimal import Decimal import json -from django.http import HttpResponseBadRequest, JsonResponse +from django.http import HttpResponseBadRequest, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, redirect, render import stripe from accounts import resource_action from accounts.models import IAmPrincipal -from goodtimes.utils import ApiResponse -from django.contrib.auth import authenticate, login +from django.contrib.auth import login import jwt -from rest_framework_simplejwt.tokens import AccessToken from django.utils import timezone from django.contrib.auth import get_user_model +from goodtimes.services import StripeService +from manage_coupons.models import Coupon from manage_subscriptions.forms import ( - PlanForm, SubscriptionForm, PrincipalSubscriptionForm, + SubscriptionUpdateForm, ) from manage_wallets.models import ( PaymentMethod, @@ -22,18 +22,22 @@ from manage_wallets.models import ( TransactionStatus, TransactionType, ) -from .models import Plan, Subscription, PrincipalSubscription +from .models import ( + Subscription, + PrincipalSubscription, + SubscriptionStatus, +) from django.views import generic -from rest_framework import status from django.contrib.auth.mixins import LoginRequiredMixin -from django.urls import reverse_lazy +from django.urls import reverse, reverse_lazy from django.contrib import messages from goodtimes import constants from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from django.conf import settings -from rest_framework.permissions import IsAuthenticated from django.views.generic.base import TemplateView +from django.db.models import Q +from django.db import transaction # Create your views here. @@ -80,6 +84,11 @@ class SubscriptionCreateOrUpdateView(LoginRequiredMixin, generic.View): if self.object is not None: self.action = resource_action.ACTION_UPDATE + if self.object: + self.form_class = SubscriptionUpdateForm + else: + self.form_class = self.form_class + form = self.form_class(instance=self.object) context = self.get_context_data(form=form) return render(request, self.template_name, context=context) @@ -91,16 +100,30 @@ class SubscriptionCreateOrUpdateView(LoginRequiredMixin, generic.View): if self.object is not None: self.action = resource_action.ACTION_UPDATE - form = self.form_class(request.POST, instance=self.object) + if self.object: + form = SubscriptionUpdateForm(request.POST, instance=self.object) + else: + form = self.form_class(request.POST) + if not form.is_valid(): print(form.errors) context = self.get_context_data(form=form) return render(request, self.template_name, context=context) + + # This code ensures that only one free plan can be created by checking for existing free plans before saving a new one. + if form.cleaned_data.get("is_free"): + if self.model.objects.filter(Q(is_free=True) & Q(active=True)).exists(): + messages.error( + self.request, + "A free plan is already available. Please deactivate the existing one before creating a new one.", + ) + context = self.get_context_data(form=form) + return render(request, self.template_name, context=context) + form.save() messages.success(self.request, self.get_success_message()) return redirect(self.success_url) - class SubscriptionView(LoginRequiredMixin, generic.ListView): page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS @@ -110,7 +133,12 @@ class SubscriptionView(LoginRequiredMixin, generic.ListView): context_object_name = "subscription_obj" def get_queryset(self): - queryset = super().get_queryset().filter(deleted=False, active=True) + queryset = ( + super() + .get_queryset() + .filter(deleted=False) + .prefetch_related("principal_types") + ) return queryset.order_by("-created_on") def get_context_data(self, **kwargs): @@ -119,101 +147,13 @@ class SubscriptionView(LoginRequiredMixin, generic.ListView): return context -# class SubscriptionDeleteView(LoginRequiredMixin, generic.View): -# page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS -# resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS -# action = resource_action.ACTION_DELETE -# model = Subscription -# success_url = reverse_lazy("manage_subscriptions:subscription_list") -# success_message = constants.RECORD_DELETED -# error_message = constants.RECORD_NOT_FOUND - -# def get(self, request, pk): -# try: -# type_obj = self.model.objects.get(id=pk) -# type_obj.deleted = True -# type_obj.active = False -# type_obj.save() -# messages.success(request, self.success_message) -# except self.model.DoesNotExist: -# messages.success(request, self.error_message) - -# return redirect(self.success_url) - - -# class PlanCreateOrUpdateView(LoginRequiredMixin, generic.View): -# # Set the page_name and resource -# page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS -# resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS - -# # Initialize the action as ACTION_CREATE (can change based on logic) -# action = resource_action.ACTION_CREATE # Default action - -# template_name = "manage_subscriptions/plan_add.html" -# model = Plan -# form_class = PlanForm -# success_url = reverse_lazy("manage_subscriptions:plan_list") -# error_message = "An error occurred while saving the data." - -# # Determine the success message dynamically based on whether it's an update or create -# def get_success_message(self): -# self.success_message = ( -# constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED -# ) -# return self.success_message - -# # Get the object (if exists) based on URL parameter 'pk' -# def get_object(self): -# pk = self.kwargs.get("pk") -# return get_object_or_404(self.model, pk=pk) if pk else None - -# # Add page_name and operation to the context -# def get_context_data(self, **kwargs): -# context = { -# "page_name": self.page_name, -# "operation": "Add" if not self.object else "Edit", -# } -# context.update(kwargs) # Include any additional context data passed to the view -# return context - -# def get(self, request, *args, **kwargs): -# self.object = self.get_object() - -# # If an object is found, change action to ACTION_UPDATE -# if self.object is not None: -# self.action = resource_action.ACTION_UPDATE - -# form = self.form_class(instance=self.object) -# context = self.get_context_data(form=form) -# return render(request, self.template_name, context=context) - -# def post(self, request, *args, **kwargs): -# self.object = self.get_object() - -# # If an object is found, change action to ACTION_UPDATE -# if self.object is not None: -# self.action = resource_action.ACTION_UPDATE - -# form = self.form_class(request.POST, instance=self.object) -# if not form.is_valid(): -# print(form.errors) -# context = self.get_context_data(form=form) -# return render(request, self.template_name, context=context) -# form.save() -# messages.success(self.request, self.get_success_message()) -# return redirect(self.success_url) - - -class PlanView(LoginRequiredMixin, generic.ListView): +class SubscriptionDetailView(LoginRequiredMixin, generic.DetailView): page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS action = resource_action.ACTION_READ - model = Plan - template_name = "manage_subscriptions/plan_list.html" - context_object_name = "plan_obj" - - def get_queryset(self): - return super().get_queryset().filter(deleted=False) + model = Subscription + template_name = "manage_subscriptions/subscription_details.html" + context_object_name = "subscription" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -221,32 +161,35 @@ class PlanView(LoginRequiredMixin, generic.ListView): return context -# class PlanDeleteView(LoginRequiredMixin, generic.View): -# page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS -# resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS -# action = resource_action.ACTION_DELETE -# model = Plan -# success_url = reverse_lazy("manage_subscriptions:plan_list") -# success_message = constants.RECORD_DELETED -# error_message = constants.RECORD_NOT_FOUND +class SubscriptionDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + action = resource_action.ACTION_DELETE + model = Subscription + success_url = reverse_lazy("manage_subscriptions:subscription_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND -# def get(self, request, pk): -# try: -# type_obj = self.model.objects.get(id=pk) -# type_obj.deleted = True -# type_obj.active = False -# type_obj.save() -# messages.success(request, self.success_message) -# except self.model.DoesNotExist: -# messages.success(request, self.error_message) + def get(self, request, pk): + try: + # Retrieve the subscription object + subscription = self.model.objects.get(id=pk) + subscription.deleted = True + subscription.active = False + subscription.save() -# return redirect(self.success_url) + messages.success(request, self.success_message) + + except self.model.DoesNotExist: + messages.error(request, self.error_message) + + return redirect(self.success_url) class PrincipalSubscriptionCreateOrUpdateView(LoginRequiredMixin, generic.View): # Set the page_name and resource - page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS - resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + page_name = resource_action.RESOURCE_PRINCIPAL_SUBSCRIPTIONS + resource = resource_action.RESOURCE_PRINCIPAL_SUBSCRIPTIONS # Initialize the action as ACTION_CREATE (can change based on logic) action = resource_action.ACTION_CREATE # Default action @@ -323,6 +266,20 @@ class PrincipalSubscriptionView(LoginRequiredMixin, generic.ListView): return context +class PrincipalSubscriptionDetailView(LoginRequiredMixin, generic.DetailView): + page_name = resource_action.RESOURCE_PRINCIPAL_SUBSCRIPTIONS + resource = resource_action.RESOURCE_PRINCIPAL_SUBSCRIPTIONS + action = resource_action.ACTION_READ + model = PrincipalSubscription + template_name = "manage_subscriptions/principal_subscription_details.html" + context_object_name = "principal_subscription_obj" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + class PrincipalSubscriptionDeleteView(LoginRequiredMixin, generic.View): page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS @@ -345,64 +302,211 @@ class PrincipalSubscriptionDeleteView(LoginRequiredMixin, generic.View): return redirect(self.success_url) -class SubscriptionPageView(TemplateView): +class SubscriptionPageView(generic.View): template_name = "stripe_html/index.html" + model = Subscription + error_url = reverse_lazy("manage_subscriptions:error") + + def get(self, request): + if not request.user.is_authenticated: + return HttpResponseRedirect(self.error_url) + + print("request user is :", request.user) + obj = self.model.objects.filter( + principal_types=request.user.principal_type, + active=True, + is_free=False, + ) + + if not obj.exists(): + print(f"No pre-define subscription details found in {self.model} table for user_type {request.user.principal_type}") + return HttpResponseRedirect(self.error_url) + + context = { + "subscriptions": obj, + # "stripeCheckoutUrl": request.build_absolute_uri(reverse("manage_subscriptions:create_checkout_session")), + # "couponValidityCheckUrl": request.build_absolute_uri(reverse("manage_subscriptions:validate_coupon")), + "stripeCheckoutUrl": settings.STRIPE_CHECKOUT_URL, + "couponValidityCheckUrl": settings.COUPON_VALIDITY_CHECK_URL, + "stripe_public_key": settings.STRIPE_PUBLISH_KEY + } + return render(request, self.template_name, context=context) + +class ActiveSubscriptionView(generic.View): + template_name = "stripe_html/active_subscription.html" + model = IAmPrincipal def get(self, request, *args, **kwargs): - # Example of extracting the token from a query parameter or cookie token = request.GET.get("token") - # token = request.GET.get("token") or request.COOKIES.get("jwt") print("token: ", token) + if token: try: # Decode and validate token payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) - print("payload: ", payload) - try: - UserModel = get_user_model() - user = UserModel.objects.get(id=payload["user_id"]) - # Manually specify the authentication backend - user.backend = "django.contrib.auth.backends.ModelBackend" - # Log the user in - login(request, user) - print("Logged in user: ", user) + user = self.model.objects.get(id=payload["user_id"]) + # Manually specify the authentication backend + user.backend = "django.contrib.auth.backends.ModelBackend" + # Log the user in + login(request, user) + except ( + IAmPrincipal.DoesNotExist, + jwt.ExpiredSignatureError, + jwt.InvalidTokenError, + ): + return HttpResponseRedirect(reverse("manage_subscriptions:error")) - except IAmPrincipal.DoesNotExist: - # Handle expired token - return HttpResponseBadRequest("No Principal Found") - - except jwt.ExpiredSignatureError: - # Handle expired token - return HttpResponseBadRequest("Expired Signature Error") - except jwt.InvalidTokenError: - return HttpResponseBadRequest("Invalid Token Error") - - return super().get(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - request = self.request + today = timezone.now().date() if request.user.is_authenticated: - print("request.user: ", request.user) - subscriptions = Subscription.objects.filter( - principal_types=request.user.principal_type - ) + latest_subscription = PrincipalSubscription.get_active_princial_subscription(request.user) - if subscriptions.exists(): - context["subscriptions"] = subscriptions - context["stripeCheckoutUrl"] = settings.STRIPE_CHECKOUT_URL - context["stripeFinalUrl"] = settings.STRIPE_FINAL_URL - else: - # Handle the case where no subscriptions are found for the principal type. - context["error"] = "No subscriptions found for your user type." - return context + print(f"latest subscription reodr is {latest_subscription}") + + if not latest_subscription: + return HttpResponseRedirect(reverse("manage_subscriptions:stripe")) + + return render(request, self.template_name, context={"subscription": latest_subscription}) + return HttpResponseRedirect(reverse("manage_subscriptions:error")) + +class CancelAutoSubscriptionView(LoginRequiredMixin, generic.View): + model = PrincipalSubscription + error_url = reverse_lazy("manage_subscriptions:error") + + def get(self, request, *args, **kwargs): + subscription_id = self.kwargs.get("subscription_id") + + try: + subscription = self.model.objects.get( + id=subscription_id, principal=request.user + ) + except self.model.DoesNotExist: + messages.error(request, "Subscription not found.") + return redirect("manage_subscriptions:error") + + try: + if subscription.stripe_subscription_id: + data = StripeService.cancel_auto_renew_subscription(subscription.stripe_subscription_id) + if not data["success"]: + return redirect(self.error_url) + + self.model.cancel_stipe_auto_renew_subscription(subscription) + + except Exception as e: + print(f'an error occur {str(e)}') + messages.error(request, f"An error occurred while cancelling the subscription {str(e)}") + return redirect(self.error_url) + + return redirect(reverse_lazy("manage_subscriptions:active")) + + # def post(self, request, *args, **kwargs): + # subscription_id = request.POST.get("subscription_id") + + # try: + # subscription = PrincipalSubscription.objects.get( + # id=subscription_id, principal=request.user + # ) + # except PrincipalSubscription.DoesNotExist: + # messages.error(request, "Subscription not found.") + # return redirect("manage_subscriptions:cancel") + + # try: + # with transaction.atomic(): + # if subscription.is_stripe_subscription: + # # Cancel Stripe subscription + # stripe.Subscription.modify( + # subscription.stripe_subscription_id, cancel_at_period_end=True + # ) + + # # Updating subscription status in the local database + # subscription.status = SubscriptionStatus.INACTIVE + # subscription.cancelled = True + # subscription.auto_renew = False + # subscription.cancelled_date_time = timezone.now() + # subscription.save() + + # messages.success(request, "Subscription cancelled successfully.") + # return redirect("manage_subscriptions:subscription_cancel_success") + # except stripe.error.InvalidRequestError as e: + # messages.error(request, f"Stripe error: {str(e)}") + # return redirect("manage_subscriptions:subscription_cancel_fails") @csrf_exempt -def stripe_config(request): - if request.method == "GET": - stripe_config = {"publicKey": settings.STRIPE_PUBLISH_KEY} - return JsonResponse(stripe_config, safe=False) +@require_POST +def validate_coupon(request): + data = json.loads(request.body) + coupon_code = data.get("couponCode", None) + subscription_id = data.get("subscriptionId", None) + final_amount = None + + try: + subscription = Subscription.objects.get(id=subscription_id) + except Subscription.DoesNotExist: + return JsonResponse({"error": "Subscription not found."}, status=404) + + # If no coupon code is provided, assume no discount and proceed + if not coupon_code: + return JsonResponse({"message": "No coupon code provided."}, status=200) + + # Validating Coupon + try: + coupon = Coupon.objects.get(coupon_code=coupon_code) + + if not coupon.is_valid(): + return JsonResponse({"error": "Coupon is not valid."}, status=400) + + # Check discount amount + if coupon.discount_amount and coupon.discount_amount > subscription.amount: + final_amount = subscription.amount - coupon.discount_amount + return JsonResponse( + {"error": "Coupon discount amount exceeds subscription amount."}, + status=400, + ) + + # Check discount percentage + if coupon.discount_percentage: + discount = ( + coupon.discount_percentage / Decimal("100") + ) * subscription.amount + if discount > subscription.amount: + return JsonResponse( + { + "error": "Coupon discount percentage exceeds subscription amount." + }, + status=400, + ) + final_amount = subscription.amount - discount + # Retrieving coupon from Stripe if applicable + if coupon.coupon_id: + try: + stripe_coupon = stripe.Coupon.retrieve(coupon.coupon_id) + print("stripe_coupon: ", stripe_coupon) + if ( + stripe_coupon.max_redemptions + and stripe_coupon.times_redeemed >= stripe_coupon.max_redemptions + ): + return JsonResponse( + {"error": "Coupon max redeems reached."}, status=400 + ) + return JsonResponse( + {"data": {"coupon": stripe_coupon, "finalAmount": final_amount}}, + status=200, + ) + except stripe.error.InvalidRequestError: + return JsonResponse( + {"error": f"Invalid coupon code: {coupon_code}"}, status=400 + ) + except stripe.error.StripeError as e: + return JsonResponse({"error": f"Stripe error: {str(e)}"}, status=400) + else: + return JsonResponse( + { + "error": "Coupon is either invalid, expired, or not associated with Stripe." + }, + status=400, + ) + except Coupon.DoesNotExist: + return JsonResponse({"error": "Coupon not found."}, status=404) @csrf_exempt @@ -410,97 +514,82 @@ def stripe_config(request): def create_checkout_session(request): stripe.api_key = settings.STRIPE_SECRET_KEY data = json.loads(request.body) - print("data: ", data) - subscription_id = data.get("subscriptionId", None) + subscription_id = data.get("subscriptionId") + coupon_code = data.get("couponCode") + transaction_amount = data.get("finalAmount") + is_recurring = data.get("isRecurring") + principal_id = request.user.id + + print(f"subscription data is {subscription_id}, {coupon_code}, { is_recurring}") try: subscription = Subscription.objects.get(id=subscription_id) except Subscription.DoesNotExist: - return ApiResponse.error( - status=status.HTTP_404_NOT_FOUND, message="Subscription not found." - ) + return JsonResponse({"error": "Subscription not found."}, status=404) - order_id = ( - "order_" + str(timezone.localtime().timestamp()) + str(request.user.email) - ) - print("order_id: ", order_id) - - # Create a Transaction object with status INITIATE - transaction = Transaction.objects.create( - principal=request.user, - principal_subscription=None, # Since the subscription is not created yet - transaction_type=TransactionType.PAYMENT, # or PAYMENT, as applicable - payment_method=PaymentMethod.CARD, # Assuming CARD for this example - transaction_status=TransactionStatus.INITIATE, - amount=subscription.amount, # Fetching amount from the Subscription object - order_id=order_id, - comment="Principal Subscription Initiated", - ) - - # subscription_days = subscription.plan.days - # today = timezone.now().date() - # last_date = today + timedelta(days=int(subscription_days)) - - # To Avoid Duplicacy of Principal Subscription - # principal_subscription = PrincipalSubscription.objects.create( - # principal=request.user, - # subscription=subscription, - # is_paid=False, - # order_id=order_id, - # start_date=today, - # end_date=last_date, - # grace_period_end_date=last_date + timedelta(days=15), - # ) + # Default transaction amount based on subscription amount + session_data = { + "payment_method_types": ["card"], + "success_url": request.build_absolute_uri("/subscriptions/success/"), + "cancel_url": request.build_absolute_uri("/subscriptions/cancel/"), + "metadata": { + "transaction_amount": str(subscription.amount), + "principal": str(principal_id), + "principal_email": str(request.user.email), + "subscription_id": str(subscription.id), + "product_id": subscription.product_id, + "couponCode": coupon_code if coupon_code else None, + }, + } + print("session_data: ", session_data) + # Coupon Handling + if coupon_code: + try: + stripe_coupon = stripe.Coupon.retrieve(coupon_code) + session_data["discounts"] = [{"coupon": stripe_coupon.id}] + except stripe.error.InvalidRequestError: + return JsonResponse( + {"error": f"Invalid coupon code: {coupon_code}"}, status=400 + ) + except stripe.error.StripeError as e: + return JsonResponse({"error": f"Stripe error: {str(e)}"}, status=400) + # Creating the Stripe Checkout Session try: - # customer = stripe.Customer.create( - # email=request.user.email, - # shipping={ - # "name": request.user.first_name, - # "address": { - # "line1": request.user.city, - # "city": request.user.city, - # "postal_code": "SW1A 2AA", - # "country": request.user.address_line1, # Adjust accordingly - # }, - # }, - # ) - - # Create a checkout session - checkout_session = stripe.checkout.Session.create( - payment_method_types=["card"], - # customer=customer.id, # Optional: Link the session to the Stripe customer created above - line_items=[ + if is_recurring and subscription.price_id: + session_data["line_items"] = [ + { + "price": subscription.price_id, + "quantity": 1, + } + ] + session_data["mode"] = "subscription" + session_data["subscription_data"] = { + "metadata": session_data["metadata"], + } + else: + session_data["line_items"] = [ { "price_data": { "currency": "gbp", "product_data": { - "name": subscription.title, # Adjust with your subscription/product name + "name": subscription.title, + "description": subscription.short_description, }, - "unit_amount": int( - subscription.amount * 100 - ), # Unit amount in cents/pence - "tax_behavior": "inclusive", # or 'exclusive', based on your tax settings + "unit_amount": int(subscription.amount * 100), }, "quantity": 1, } - ], - mode="payment", - success_url=request.build_absolute_uri("/subscriptions/success/"), - cancel_url=request.build_absolute_uri("/subscriptions/cancel/"), - metadata={ - "principal": str(request.user.id), - "order_id": str(order_id), - "subscription_id": str(subscription.id), - "transaction_id": str(transaction.id), - # "principal_subscription_id": str(principal_subscription.id), - }, - ) + ] + session_data["mode"] = "payment" + checkout_session = stripe.checkout.Session.create(**session_data) return JsonResponse({"sessionId": checkout_session["id"]}) except Exception as e: - return JsonResponse({"error": str(e)}) + return JsonResponse({"error": str(e)}, status=500) +class ErrorView(TemplateView): + template_name = "stripe_html/webview_404.html" class SuccessView(TemplateView): template_name = "stripe_html/success.html" @@ -509,36 +598,9 @@ class CancelView(TemplateView): template_name = "stripe_html/cancel.html" -# class IndexView(TemplateView): -# template_name = "stripe_html/index.html" +class SubscriptionCancelSuccessView(TemplateView): + template_name = "stripe_html/subscription_cancel_success.html" -# def get(self, request, *args, **kwargs): -# # Example of extracting the token from a query parameter or cookie -# token = request.GET.get("token") -# # token = request.GET.get("token") or request.COOKIES.get("jwt") -# print("token: ", token) -# if token: -# try: -# # Decode and validate token -# payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) -# print("payload: ", payload) -# try: -# UserModel = get_user_model() -# user = UserModel.objects.get(id=payload["user_id"]) -# # Manually specify the authentication backend -# user.backend = "django.contrib.auth.backends.ModelBackend" -# # Log the user in -# login(request, user) -# print("Logged in user: ", user) -# except IAmPrincipal.DoesNotExist: -# # Handle expired token -# return HttpResponseBadRequest("No Principal Found") - -# except jwt.ExpiredSignatureError: -# # Handle expired token -# return HttpResponseBadRequest("Expired Signature Error") -# except jwt.InvalidTokenError: -# return HttpResponseBadRequest("Invalid Token Error") - -# return super().get(request, *args, **kwargs) +class SubscriptionCancelFailsView(TemplateView): + template_name = "stripe_html/subscription_cancel_fails.html" diff --git a/manage_wallets/api/views.py b/manage_wallets/api/views.py index 85deee9..931bbcb 100644 --- a/manage_wallets/api/views.py +++ b/manage_wallets/api/views.py @@ -140,7 +140,13 @@ class TransactionView(APIView): permission_classes = [IsAuthenticated] def get(self, request): - queryset = models.Transaction.objects.filter(principal_id=request.user.id) + queryset = models.Transaction.objects.filter( + principal_id=request.user.id, + transaction_status__in=[ + models.TransactionStatus.SUCCESS, + models.TransactionStatus.FAIL, + ], + ).order_by("-created_on") serializer = serializers.TransactionSerializer(queryset, many=True) response = { diff --git a/requirements.txt b/requirements.txt index 08ccda6..0724efc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,21 +6,24 @@ autobahn==23.6.2 Automat==22.10.0 certifi==2024.2.2 cffi==1.16.0 -channels==4.0.0 +channels==4.1.0 channels-redis==4.2.0 +chardet==5.2.0 charset-normalizer==3.3.2 colorama==0.4.6 colorlog==6.8.2 constantly==23.10.4 cryptography==42.0.2 -daphne==4.1.0 +daphne==4.1.2 defusedxml==0.7.1 Django==5.0.2 django-allauth==0.61.1 django-cors-headers==4.3.1 +django-crontab==0.7.1 django-debug-toolbar==4.3.0 django-environ==0.11.2 django-extensions==3.2.3 +django-filter==24.2 django-phonenumber-field==7.3.0 django-quill-editor==0.1.40 django-taggit==5.0.1 @@ -30,6 +33,7 @@ djangorestframework==3.14.0 djangorestframework-simplejwt==5.3.1 et-xmlfile==1.1.0 googlemaps==4.10.0 +gunicorn==23.0.0 h11==0.14.0 httpcore==1.0.4 httpx==0.27.0 @@ -42,7 +46,10 @@ mysqlclient==2.2.4 numpy==1.26.4 oauthlib==3.2.2 onesignal-sdk==2.0.0 +openpyxl==3.1.4 orjson==3.9.15 +packaging==24.2 +pandas==2.2.2 phonenumbers==8.13.30 pillow==10.2.0 pyasn1==0.5.1 @@ -52,10 +59,12 @@ PyJWT==2.8.0 pyngrok==7.1.2 pyOpenSSL==24.0.0 python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 python3-openid==3.2.0 pytz==2024.1 PyYAML==6.0.1 redis==5.0.2 +reportlab==4.2.0 requests==2.31.0 requests-oauthlib==1.3.1 service-identity==24.1.0 @@ -64,8 +73,9 @@ sniffio==1.3.1 sqlparse==0.4.4 stripe==8.2.0 tqdm==4.66.2 +tweepy==4.14.0 Twisted==23.10.0 -twisted-iocpsupport==1.0.4 +# twisted-iocpsupport==1.0.4 txaio==23.1.1 typing_extensions==4.9.0 tzdata==2024.1 diff --git a/static/images/category/business.png b/static/images/category/business.png new file mode 100644 index 0000000..e4b9bf5 Binary files /dev/null and b/static/images/category/business.png differ diff --git a/static/images/category/cultural.png b/static/images/category/cultural.png new file mode 100644 index 0000000..b76f8a8 Binary files /dev/null and b/static/images/category/cultural.png differ diff --git a/static/images/category/education.png b/static/images/category/education.png new file mode 100644 index 0000000..615b824 Binary files /dev/null and b/static/images/category/education.png differ diff --git a/static/images/category/entertainment.png b/static/images/category/entertainment.png new file mode 100644 index 0000000..52cba2a Binary files /dev/null and b/static/images/category/entertainment.png differ diff --git a/static/images/category/health.png b/static/images/category/health.png new file mode 100644 index 0000000..75b4ec3 Binary files /dev/null and b/static/images/category/health.png differ diff --git a/static/images/category/liesure.png b/static/images/category/liesure.png new file mode 100644 index 0000000..6feb43c Binary files /dev/null and b/static/images/category/liesure.png differ diff --git a/static/images/category/outdoor.png b/static/images/category/outdoor.png new file mode 100644 index 0000000..1311fef Binary files /dev/null and b/static/images/category/outdoor.png differ diff --git a/static/images/category/recreation.png b/static/images/category/recreation.png new file mode 100644 index 0000000..7125301 Binary files /dev/null and b/static/images/category/recreation.png differ diff --git a/static/img/facebook.png b/static/img/facebook.png new file mode 100644 index 0000000..8400a87 Binary files /dev/null and b/static/img/facebook.png differ diff --git a/static/img/forward_all_icon.png b/static/img/forward_all_icon.png new file mode 100644 index 0000000..6b6f4d1 Binary files /dev/null and b/static/img/forward_all_icon.png differ diff --git a/static/img/instagram.png b/static/img/instagram.png new file mode 100644 index 0000000..b61a965 Binary files /dev/null and b/static/img/instagram.png differ diff --git a/static/img/x_twitter.png b/static/img/x_twitter.png new file mode 100644 index 0000000..62887fd Binary files /dev/null and b/static/img/x_twitter.png differ diff --git a/static/src/assets/css/payment/style.css b/static/src/assets/css/payment/style.css index e20914f..5fb6746 100644 --- a/static/src/assets/css/payment/style.css +++ b/static/src/assets/css/payment/style.css @@ -1,808 +1,872 @@ +@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap') @import url('https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap') * { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: "Poppins", sans-serif; +} + +:root { + --black: #000000; + --light-black: #050505; + --main-yellow: rgba(209, 170, 88, 1); + --light-yellow: rgba(229, 25, 94, 0.2); + --white: #ffffff; + --light-white: #f8f8f8; + --white-other: rgba(207, 207, 207, 1); + --white-mix: #cecece; + --border: #ff72a285; +} + +body { + font-family: "Poppins", sans-serif; +} + +.ptb { + padding: 40px 0; +} + +.sec-heading { + font-size: 38px; + font-weight: 600; + text-align: center; + /* padding-top: 40px; */ + color: var(--main-yellow); +} + +.sec-subheading { + font-size: 18px; + font-weight: 400; + text-align: center; + color: var(--white); +} - @import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap') @import url('https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap') * { - margin: 0; - padding: 0; - box-sizing: border-box; - font-family: "Poppins", sans-serif; +.big-heading { + font-size: 52px; + font-weight: 700; + color: var(--white); + letter-spacing: 1.8px; +} + + +.para { + font-size: 18px; + font-weight: 400; + color: rgba(255, 255, 255, 1); +} + +.sec-mini-heading { + font-size: 24px; + font-weight: 600; +} + + +.para-dark { + font-size: 24px; + /* font-weight: 600; */ +} + +.para-mid { + font-size: 18px; + color: rgba(255, 255, 255, 0.69); +} + +li { + list-style: none; +} + +.pt { + padding: 40px 0; +} + + +/* header */ + +header { + border-bottom: 1px solid var(--main-yellow); + position: absolute; + background-color: transparent; + top: 0; + left: 0; + width: 100%; + padding: 22px 0; + display: flex; + align-items: center; + z-index: 9999; +} + +header .header-main-inner { + display: flex; + align-items: center; + justify-content: space-between; +} + +header nav ul { + display: flex; + gap: 80px; + align-items: center; + margin: 0; +} + + +.header-main-inner .logo { + width: 212px; + height: 52px; +} + +.header-main-inner .logo img { + width: 100%; +} + + +header nav ul .menu-btn img { + width: 24px; + height: 24px; +} + +header nav ul li a { + color: var(--white); + font-size: 18px; + /* font-weight: 600; */ + position: relative; + text-decoration: none; +} + +.sticky { + background-color: var(--black); + position: fixed; + animation: slideDown 0.8s ease-out; + -webkit-animation: slideDown 0.8s ease-out; +} + +@keyframes slideDown { + 0% { + transform: translateY(-100%); } - :root { - --black: #000000; - --light-black: #050505; - --main-yellow: rgba(209, 170, 88, 1); - --light-yellow: rgba(229, 25, 94, 0.2); - --white: #ffffff; - --light-white: #f8f8f8; - --white-other: rgba(207, 207, 207, 1); - --white-mix: #cecece; - --border: #ff72a285; + 100% { + transform: translateY(0); } +} - body { - font-family: "Poppins", sans-serif; +header a.active { + color: var(--main-yellow); +} + +header nav ul li a::after { + content: ""; + position: absolute; + left: 50%; + width: 0%; + bottom: -10px; + border-bottom: 2px solid var(--main-yellow); + transition: all 0.3s; + transform: translateX(-50%); +} + +header nav ul li a:hover { + color: var(--main-yellow); +} + +header nav ul li a:hover:after { + width: 100%; +} + +.hamburger { + display: none; +} + +.hamburger { + position: relative; + width: 25px; + height: 25px; + display: none; + cursor: pointer; +} + +.hamburger img { + width: 25px; + height: 25px; +} + + +.overlay { + display: none; +} + +.cross-btn { + padding: 2px 20px; + text-align: right; + display: none; + font-size: 40px; + cursor: pointer; + color: var(--main-yellow); +} + +.cross-btn i { + font-size: 20px; + font-weight: 500; +} + +/* about-head */ +.head-sec header, +.terms-sec header { + /* position: inherit; */ + background-color: var(--black); +} + + + + +/* baner */ +.baner-section { + background-image: linear-gradient(rgba(4, 9, 10, 0.7), rgba(4, 9, 10, 0.7)), + url("https://goodtimes.betadelivery.com/static/images/baner.jpg"); + background-position: center; + background-size: cover; + height: 100vh; + display: flex; + align-items: center; +} + +.baner-section .row { + align-items: center; + padding-top: 100px; +} + +.baner-section .store-app { + display: flex; + align-items: center; + gap: 15px; + margin: 30px 0; +} + +.baner-img { + text-align: center; + padding-top: 20px; +} + +.baner-section .baner-img img { + width: 75%; +} + +.baner-content .big-heading span { + color: var(--main-yellow); +} + +.baner-content .grey-para { + margin-top: 24px; + font-size: 20px; + color: var(--white-other); +} + + +.baner-btn { + margin-top: 24px; +} + + +/* plan */ + +.modal-body .your-plan { + margin-top: 15px; +} + +.modal-body .your-plans-main { + padding: 0px 10px 30px; +} + +.modal-body .your-plans-main .head { + font-size: 25px; + color: black; + font-weight: 500; +} + +.modal-body .your-plans-main .monthly-div-main { + box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px; + padding: 15px 20px; + border-radius: 8px; +} + +.modal-body .your-plans-main .monthly-div-main .monthly-div { + box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid rgb(215 169 72 / 28%); +} + +.modal-body .your-plans-main .your-heading { + color: var(--main-yellow); + font-size: 24px; + font-weight: 500; +} + +.modal-body .your-plans-main .your-subheading { + font-size: 24px; + font-weight: 500; + color: black; +} + +.modal-body .your-plans-main .your-subheading span { + font-size: 18px; + font-weight: 400; +} + +.modal-body .your-plans-btn { + text-align: center; + margin-top: 20px; +} + +.modal-body .your-plans-btn .common-btn { + width: 100%; +} + +/* plan end */ + +.common-btn { + background: linear-gradient(90.02deg, #CDA34C 0.02%, #F1D6A0 52%, #D1A956 98.68%); + font-weight: 500; + border: none; + font-size: 18px; + font-weight: 600; + padding: 10px 40px; + border-radius: 5px; + width: 100%; +} + + +/* key-feature */ +.key-features { + background-color: #10100e; + color: white; +} + +.key-features .row { + align-items: center; + padding-bottom: 40px; +} + +.key-features .key-main-img img { + width: 75%; +} + +.key-features .key-right-first, +.key-right-second { + display: flex; + align-items: start; + gap: 20px; +} + +.key-right-second { + margin-top: 60px; +} + +/* easy-steps */ +.easy-steps { + background-color: var(--black); +} + +.easy-steps .para-dark { + margin-top: 50px; + color: var(--white); +} + +.easy-steps .para { + color: rgba(192, 192, 192, 1); + text-align: center; +} + +.easy-steps-main { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 25px; + margin-top: 100px; + padding-bottom: 40px; +} + +.easy-steps-first { + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid var(--main-yellow); + border-top-left-radius: 14px; + border-top-right-radius: 14px; + display: flex; + align-items: center; + flex-direction: column; + position: relative; + height: 400px; + padding: 0px 20px; +} + +.easy-steps-first-img-num { + width: 75px; + height: 75px; + position: absolute; + top: -50px; +} + +img.easy-steps-first-img-bot { + position: absolute; + bottom: -1px; +} + +/* Adventure + */ + + +.Adventure { + background-color: #10100e; + color: white; +} + +.Adventure .row { + align-items: center; + padding-top: 40px; +} + +.Adventure-rti { + display: flex; + align-items: center; + gap: 20px; + margin-bottom: 40px; +} + +.Adventure-right img { + width: 75%; +} + +.Adventure-left .store-app { + display: flex; + gap: 15px; +} + +.Adventure-btn { + margin-top: 40px; +} + + +/* faq */ +.faq { + background-color: black; +} + +.main-faq { + padding: 40px 0 80px; +} + +div#accordionExample { + display: flex; + flex-direction: column; + gap: 30px; +} + +.faq .accordion-item { + border: 1px solid var(--main-yellow); + background-color: transparent; + color: #fff; + border-radius: 5px; +} + +.faq button.accordion-button:focus { + box-shadow: inherit; +} + +.faq button.accordion-button { + background-color: transparent; + color: var(--white); +} + +.accordion-button:not(.collapsed) { + color: var(--main-yellow) !important; + font-family: "Nunito Sans", sans-serif; + font-weight: 600; +} + +.accordion-item:first-of-type .accordion-button { + box-shadow: none; +} + +.accordion-button::after { + background-image: url(images/ab.png); +} + +.accordion-button:not(.collapsed)::after { + background-image: url(images/at.png); + transform: none; +} + + + +/* footer */ +.footer { + background-color: rgba(16, 16, 14, 1); +} + +.footer .footer-main-img { + width: 212px; + height: 52px; + padding: 40px 0; +} + +.footer .footer-main-img img { + width: 100%; +} + +.footer .footer-main-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + color: var(--white); + padding: 3rem 0 2rem; +} + +.footer .footer-main-grid .para-dark { + font-size: 18px; + font-weight: 600; +} + +.footer .footer-main-grid .para { + font-size: 16px; +} + + +.footer .store-app { + display: flex; + gap: 15px; + margin: 0; + flex-direction: column; +} + +.footer-btn .common-btn { + margin-bottom: 16px; +} + +.footer-main-grid-fourth { + margin-top: -20px; +} + +.copy-right { + color: var(--main-yellow); + text-decoration: none; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 20px; +} + +.store-app img { + width: 165px; +} + + +/* About us */ + +.about-us { + background-image: url(images/About\ Us\ Banner.png); + background-position: top; + background-size: cover; + background-repeat: no-repeat; + height: 500px; + display: flex; + align-items: center; + margin-top: 90px; +} + +.about-us .row { + align-items: center; +} + + +.about, +.terms { + background-color: var(--black); + color: var(--white); + padding: 40px 0 70px; +} + + +.terms-main { + background-image: url(images/terms.png); + background-position: top; + background-size: cover; + background-repeat: no-repeat; + height: 500px; + display: flex; + align-items: center; + margin-top: 90px; +} + +.card_design { + background: #000; + padding: 40px 0; +} + +.gold-text { + color: rgb(209 170 88); +} + +.bg_color { + background-color: #e5e2e2; + border: none; + color: #000; + font-weight: 600; + border-radius: 5px; +} + +.feat-card { + border: 1px solid #d1aa588c; + padding: 35px; + border-radius: 6px; + background-color: #00000080; + text-align: start; + margin-bottom: 15px; +} + +/* New css */ +.feat-card .para { + font-size: 18px; + } + .currency{ + font-size: 36px; + color: #fff; + margin-right:5px + } + .interval{ + font-size: 14px; + color: gray; + } + + .actual-price { + text-decoration: line-through; + color: #ccc; +} + .offer-price { + font-size: 36px; + /* font-weight: bold; */ + color: #fff; +} + + +/* mediascreen */ +@media (max-width: 1199px) { + .big-heading br { + display: none; } +} - .ptb { - padding: 40px 0; - } - - .sec-heading { - font-size: 38px; - font-weight: 600; - text-align: center; - /* padding-top: 40px; */ - color: var(--main-yellow); - } - - .sec-subheading { - font-size: 18px; - font-weight: 400; - text-align: center; - color: var(--white); +@media (max-width: 1024px) { + .big-heading { + font-size: 42px; } +} +@media (max-width: 991px) { + /* .big-heading br { + display: none; + } */ .big-heading { - font-size: 52px; - font-weight: 700; - color: var(--white); - letter-spacing: 1.8px; + font-size: 35px; } - - .para { - font-size: 18px; - font-weight: 400; - color: rgba(255, 255, 255, 1); + .store-app img { + width: 142px; } .sec-mini-heading { - font-size: 24px; - font-weight: 600; - } - - - .para-dark { - font-size: 24px; - /* font-weight: 600; */ - } - - .para-mid { font-size: 18px; - color: rgba(255, 255, 255, 0.69); } - li { - list-style: none; + .para { + font-size: 14px; } - .pt { - padding: 40px 0; + .easy-steps-main { + grid-template-columns: repeat(2, 1fr); + gap: 70px 20px; + margin-top: 70px; } - - /* header */ - - header { - border-bottom: 1px solid var(--main-yellow); - position: absolute; - background-color: transparent; - top: 0; - left: 0; - width: 100%; - padding: 22px 0; - display: flex; - align-items: center; - z-index: 9999; + .footer .footer-main-grid { + grid-template-columns: repeat(2, 1fr); } - header .header-main-inner { - display: flex; - align-items: center; - justify-content: space-between; + .footer-main-grid-fourth { + margin-top: 0px; } - header nav ul { - display: flex; - gap: 80px; - align-items: center; - margin: 0; +} + + + +@media (max-width: 767px) { + .ptb { + padding: 20px 0 40px 0; } - - .header-main-inner .logo { - width: 212px; - height: 52px; - } - - .header-main-inner .logo img { - width: 100%; - } - - - header nav ul .menu-btn img { - width: 24px; - height: 24px; - } - - header nav ul li a { - color: var(--white); - font-size: 18px; - /* font-weight: 600; */ - position: relative; - text-decoration: none; - } - - .sticky { - background-color: var(--black); - position: fixed; - animation: slideDown 0.8s ease-out; - -webkit-animation: slideDown 0.8s ease-out; - } - - @keyframes slideDown { - 0% { - transform: translateY(-100%); - } - - 100% { - transform: translateY(0); - } - } - - header a.active { - color: var(--main-yellow); - } - - header nav ul li a::after { - content: ""; - position: absolute; - left: 50%; - width: 0%; - bottom: -10px; - border-bottom: 2px solid var(--main-yellow); - transition: all 0.3s; - transform: translateX(-50%); - } - - header nav ul li a:hover { - color: var(--main-yellow); - } - - header nav ul li a:hover:after { - width: 100%; - } - - .hamburger { - display: none; - } - - .hamburger { - position: relative; - width: 25px; - height: 25px; - display: none; - cursor: pointer; - } - - .hamburger img { - width: 25px; - height: 25px; - } - - .overlay { - display: none; + position: fixed; + width: 100%; + height: 100%; + top: 0; + left: -100%; + background-color: #60606054; + } .cross-btn { - padding: 2px 20px; - text-align: right; - display: none; - font-size: 40px; - cursor: pointer; - color: var(--main-yellow); + display: block; } - .cross-btn i { - font-size: 20px; - font-weight: 500; + .hamburger, + .overlay { + display: block; } - /* about-head */ - .head-sec header, - .terms-sec header { - /* position: inherit; */ - background-color: var(--black); + .navs { + position: fixed; + top: 0; + left: -100%; + width: 300px; + height: 100%; + background: rgb(0 0 0); + transition: all .3s; + z-index: 1; + } + + .navs ul { + flex-direction: column; + padding: 00px 20px; + align-items: start; + gap: 14px; + } + + .navs ul li a { + color: whitesmoke; + } + + .common-btn { + width: 175px; + height: 40px; } - - - /* baner */ .baner-section { - background-image: linear-gradient(rgba(4, 9, 10, 0.7), rgba(4, 9, 10, 0.7)), - url("https://goodtimes.betadelivery.com/static/images/baner.jpg"); - background-position: center; - background-size: cover; - height: 100vh; - display: flex; - align-items: center; + height: inherit; + padding: 40px 0; } .baner-section .row { - align-items: center; - padding-top: 100px; - } - - .baner-section .store-app { - display: flex; - align-items: center; - gap: 15px; - margin: 30px 0; - } - - .baner-img { - text-align: center; - padding-top: 20px; - } - - .baner-section .baner-img img { - width: 75%; - } - - .baner-content .big-heading span { - color: var(--main-yellow); - } - - .baner-content .grey-para { - margin-top: 24px; - font-size: 20px; - color: var(--white-other); + flex-direction: column-reverse; } + /* .baner-section .store-app { + justify-content: center; + } */ .baner-btn { - margin-top: 24px; - } - - - /* plan */ - - .modal-body .your-plan { - margin-top: 15px; - } - - .modal-body .your-plans-main { - padding: 0px 10px 30px; - } - - .modal-body .your-plans-main .head { - font-size: 25px; - color: black; - font-weight: 500; - } - - .modal-body .your-plans-main .monthly-div-main { - box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px; - padding: 15px 20px; - border-radius: 8px; - } - - .modal-body .your-plans-main .monthly-div-main .monthly-div { - box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px; - padding: 6px 10px; - border-radius: 8px; - border: 1px solid rgb(215 169 72 / 28%); - } - - .modal-body .your-plans-main .your-heading { - color: var(--main-yellow); - font-size: 24px; - font-weight: 500; - } - - .modal-body .your-plans-main .your-subheading { - font-size: 24px; - font-weight: 500; - color: black; - } - - .modal-body .your-plans-main .your-subheading span { - font-size: 18px; - font-weight: 400; - } - - .modal-body .your-plans-btn { - text-align: center; - margin-top: 20px; - } - - .modal-body .your-plans-btn .common-btn { - width: 100%; - } - - /* plan end */ - - .common-btn { - background: linear-gradient(90.02deg, #CDA34C 0.02%, #F1D6A0 52%, #D1A956 98.68%); - width: 252px; - /* height: 50px; */ - font-weight: 500; - border: none; - font-size: 18px; - font-weight: 600; - padding: 12px 0; - } - - - /* key-feature */ - .key-features { - background-color: #10100e; - color: white; - } - - .key-features .row { - align-items: center; - padding-bottom: 40px; - } - - .key-features .key-main-img img { - width: 75%; - } - - .key-features .key-right-first, - .key-right-second { - display: flex; - align-items: start; - gap: 20px; - } - - .key-right-second { - margin-top: 60px; - } - - /* easy-steps */ - .easy-steps { - background-color: var(--black); - } - - .easy-steps .para-dark { - margin-top: 50px; - color: var(--white); - } - - .easy-steps .para { - color: rgba(192, 192, 192, 1); text-align: center; } .easy-steps-main { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 25px; - margin-top: 100px; - padding-bottom: 40px; - } - - .easy-steps-first { - background-color: rgba(255, 255, 255, 0.05); - border: 1px solid var(--main-yellow); - border-top-left-radius: 14px; - border-top-right-radius: 14px; - display: flex; - align-items: center; - flex-direction: column; - position: relative; - height: 400px; - padding: 0px 20px; - } - - .easy-steps-first-img-num { - width: 75px; - height: 75px; - position: absolute; - top: -50px; - } - - img.easy-steps-first-img-bot { - position: absolute; - bottom: -1px; - } - - /* Adventure - */ - - - .Adventure { - background-color: #10100e; - color: white; - } - - .Adventure .row { - align-items: center; - padding-top: 40px; - } - - .Adventure-rti { - display: flex; - align-items: center; - gap: 20px; - margin-bottom: 40px; - } - - .Adventure-right img { - width: 75%; - } - - .Adventure-left .store-app { - display: flex; - gap: 15px; - } - - .Adventure-btn { - margin-top: 40px; - } - - - /* faq */ - .faq { - background-color: black; - } - - .main-faq { - padding: 40px 0 80px; - } - - div#accordionExample { - display: flex; - flex-direction: column; - gap: 30px; - } - - .faq .accordion-item { - border: 1px solid var(--main-yellow); - background-color: transparent; - color: #fff; - border-radius: 5px; - } - - .faq button.accordion-button:focus { - box-shadow: inherit; - } - - .faq button.accordion-button { - background-color: transparent; - color: var(--white); - } - - .accordion-button:not(.collapsed) { - color: var(--main-yellow) !important; - font-family: "Nunito Sans", sans-serif; - font-weight: 600; - } - - .accordion-item:first-of-type .accordion-button { - box-shadow: none; - } - - .accordion-button::after { - background-image: url(images/ab.png); - } - - .accordion-button:not(.collapsed)::after { - background-image: url(images/at.png); - transform: none; - } - - - - /* footer */ - .footer { - background-color: rgba(16, 16, 14, 1); - } - - .footer .footer-main-img { - width: 212px; - height: 52px; - padding: 40px 0; - } - - .footer .footer-main-img img { - width: 100%; + grid-template-columns: repeat(1, 1fr); } .footer .footer-main-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - color: var(--white); - padding: 3rem 0 2rem; + grid-template-columns: repeat(2, 1fr); + gap: 20px; } - .footer .footer-main-grid .para-dark { - font-size: 18px; - font-weight: 600; + .easy-steps-first { + height: 400px; } - .footer .footer-main-grid .para { + .key-features .key-main-img img { + width: 50%; + margin-bottom: 20px; + } + + .Adventure-rti { + margin-bottom: 20px; + } + + .Adventure .row { + flex-direction: column-reverse; + gap: 40px; + padding: 0; + } + + .Adventure { + padding: 30px 0; + } + + .Adventure-btn { + margin-top: 30px; + } + + .Adventure-right img { + width: 50%; + } + + .sec-heading { + font-size: 30px; + padding-top: 0; + } + + .about .para-mid, + .terms .para-mid { font-size: 16px; } - - .footer .store-app { - display: flex; - gap: 15px; - margin: 0; - flex-direction: column; + .faq { + padding: 30px 0; } - .footer-btn .common-btn { - margin-bottom: 16px; + .main-faq { + padding: 20px 0 30px; } - .footer-main-grid-fourth { - margin-top: -20px; - } - - .copy-right { - color: var(--main-yellow); - text-decoration: none; - font-size: 16px; - display: flex; - align-items: center; - justify-content: center; - padding-bottom: 20px; - } - - .store-app img { - width: 165px; - } - - - /* About us */ - - .about-us { - background-image: url(images/About\ Us\ Banner.png); - background-position: top; - background-size: cover; - background-repeat: no-repeat; - height: 500px; - display: flex; - align-items: center; - margin-top: 90px; - } - - .about-us .row { - align-items: center; - } - - - .about, - .terms { - background-color: var(--black); - color: var(--white); - padding: 40px 0 70px; - } - - - .terms-main { - background-image: url(images/terms.png); - background-position: top; - background-size: cover; - background-repeat: no-repeat; - height: 500px; - display: flex; - align-items: center; - margin-top: 90px; - } - - .gold-text { - color: rgb(209 170 88); - } - .feat-card { - border: 1px solid #d1aa588c; - padding: 18px; - width: 300px; - border-radius: 6px; - background-color: #00000080; - } - - /* mediascreen */ - @media (max-width: 1199px) { - .big-heading br { - display: none; - } - } - - @media (max-width: 1024px) { - .big-heading { - font-size: 42px; - } - } - - @media (max-width: 991px) { - /* .big-heading br { + br { display: none; - } */ - - .big-heading { - font-size: 35px; - } - - .store-app img { - width: 142px; - } - - .sec-mini-heading { - font-size: 18px; - } - - .para { - font-size: 14px; - } - - .easy-steps-main { - grid-template-columns: repeat(2, 1fr); - gap: 70px 20px; - margin-top: 70px; - } - - .footer .footer-main-grid { - grid-template-columns: repeat(2, 1fr); - } - - .footer-main-grid-fourth { - margin-top: 0px; - } - } +} - - - @media (max-width: 767px) { - .ptb { - padding: 20px 0 40px 0; - } - - .overlay { - position: fixed; - width: 100%; - height: 100%; - top: 0; - left: -100%; - background-color: #60606054; - - } - - .cross-btn { - display: block; - } - - .hamburger, - .overlay { - display: block; - } - - .navs { - position: fixed; - top: 0; - left: -100%; - width: 300px; - height: 100%; - background: rgb(0 0 0); - transition: all .3s; - z-index: 1; - } - - .navs ul { - flex-direction: column; - padding: 00px 20px; - align-items: start; - gap: 14px; - } - - .navs ul li a { - color: whitesmoke; - } - - .common-btn { - width: 175px; - height: 40px; - } - - - .baner-section { - height: inherit; - padding: 40px 0; - } - - .baner-section .row { - flex-direction: column-reverse; - } - - /* .baner-section .store-app { - justify-content: center; - } */ - - .baner-btn { - text-align: center; - } - - .easy-steps-main { - grid-template-columns: repeat(1, 1fr); - } - - .footer .footer-main-grid { - grid-template-columns: repeat(2, 1fr); - gap: 20px; - } - - .easy-steps-first { - height: 400px; - } - - .key-features .key-main-img img { - width: 50%; - margin-bottom: 20px; - } - - .Adventure-rti { - margin-bottom: 20px; - } - - .Adventure .row { - flex-direction: column-reverse; - gap: 40px; - padding: 0; - } - - .Adventure { - padding: 30px 0; - } - - .Adventure-btn { - margin-top: 30px; - } - - .Adventure-right img { - width: 50%; - } - - .sec-heading { - font-size: 30px; - padding-top: 0; - } - - .about .para-mid, - .terms .para-mid { - font-size: 16px; - } - - .faq { - padding: 30px 0; - } - - .main-faq { - padding: 20px 0 30px; - } - - br { - display: none; - } - } - - @media (max-width: 444px) { - /* .easy-steps-main { +@media (max-width: 444px) { + /* .easy-steps-main { grid-template-columns: repeat(1, 1fr); } */ - .footer .footer-main-grid { - grid-template-columns: repeat(1, 1fr); - gap: 0px; - } + .footer .footer-main-grid { + grid-template-columns: repeat(1, 1fr); + gap: 0px; + } - .footer .store-app { - margin-bottom: 16px; - } - } \ No newline at end of file + .footer .store-app { + margin-bottom: 16px; + } +} + +.coupon-code-input, +.common-btn { + display: inline-block; + margin-right: 10px; +} + +.common-btn { + /* remove default button margins */ + margin: 0; + width: 100%; +} +.feat-card input.form-control.coupon-code-input { + border-radius: 5px; + width: 100%; + background: transparent; + color: #fff; + margin-bottom: 20px; + margin-right: 0; + caret-color: #fff; + text-align: center; +} + +.feat-card input.form-control.coupon-code-input::placeholder { + color: #a5a4a4; +} \ No newline at end of file diff --git a/static/src/plugins/src/jquery-validate/jquery.validate.min.js b/static/src/plugins/src/jquery-validate/jquery.validate.min.js new file mode 100644 index 0000000..7f5f510 --- /dev/null +++ b/static/src/plugins/src/jquery-validate/jquery.validate.min.js @@ -0,0 +1,4 @@ +/*! jQuery Validation Plugin - v1.19.3 - 1/9/2021 + * https://jqueryvalidation.org/ + * Copyright (c) 2021 Jörn Zaefferer; Licensed MIT */ +!function(a){"function"==typeof define&&define.amd?define(["jquery"],a):"object"==typeof module&&module.exports?module.exports=a(require("jquery")):a(jQuery)}(function(a){a.extend(a.fn,{validate:function(b){if(!this.length)return void(b&&b.debug&&window.console&&console.warn("Nothing selected, can't validate, returning nothing."));var c=a.data(this[0],"validator");return c?c:(this.attr("novalidate","novalidate"),c=new a.validator(b,this[0]),a.data(this[0],"validator",c),c.settings.onsubmit&&(this.on("click.validate",":submit",function(b){c.submitButton=b.currentTarget,a(this).hasClass("cancel")&&(c.cancelSubmit=!0),void 0!==a(this).attr("formnovalidate")&&(c.cancelSubmit=!0)}),this.on("submit.validate",function(b){function d(){var d,e;return c.submitButton&&(c.settings.submitHandler||c.formSubmitted)&&(d=a("").attr("name",c.submitButton.name).val(a(c.submitButton).val()).appendTo(c.currentForm)),!(c.settings.submitHandler&&!c.settings.debug)||(e=c.settings.submitHandler.call(c,c.currentForm,b),d&&d.remove(),void 0!==e&&e)}return c.settings.debug&&b.preventDefault(),c.cancelSubmit?(c.cancelSubmit=!1,d()):c.form()?c.pendingRequest?(c.formSubmitted=!0,!1):d():(c.focusInvalid(),!1)})),c)},valid:function(){var b,c,d;return a(this[0]).is("form")?b=this.validate().form():(d=[],b=!0,c=a(this[0].form).validate(),this.each(function(){b=c.element(this)&&b,b||(d=d.concat(c.errorList))}),c.errorList=d),b},rules:function(b,c){var d,e,f,g,h,i,j=this[0],k="undefined"!=typeof this.attr("contenteditable")&&"false"!==this.attr("contenteditable");if(null!=j&&(!j.form&&k&&(j.form=this.closest("form")[0],j.name=this.attr("name")),null!=j.form)){if(b)switch(d=a.data(j.form,"validator").settings,e=d.rules,f=a.validator.staticRules(j),b){case"add":a.extend(f,a.validator.normalizeRule(c)),delete f.messages,e[j.name]=f,c.messages&&(d.messages[j.name]=a.extend(d.messages[j.name],c.messages));break;case"remove":return c?(i={},a.each(c.split(/\s/),function(a,b){i[b]=f[b],delete f[b]}),i):(delete e[j.name],f)}return g=a.validator.normalizeRules(a.extend({},a.validator.classRules(j),a.validator.attributeRules(j),a.validator.dataRules(j),a.validator.staticRules(j)),j),g.required&&(h=g.required,delete g.required,g=a.extend({required:h},g)),g.remote&&(h=g.remote,delete g.remote,g=a.extend(g,{remote:h})),g}}});var b=function(a){return a.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")};a.extend(a.expr.pseudos||a.expr[":"],{blank:function(c){return!b(""+a(c).val())},filled:function(c){var d=a(c).val();return null!==d&&!!b(""+d)},unchecked:function(b){return!a(b).prop("checked")}}),a.validator=function(b,c){this.settings=a.extend(!0,{},a.validator.defaults,b),this.currentForm=c,this.init()},a.validator.format=function(b,c){return 1===arguments.length?function(){var c=a.makeArray(arguments);return c.unshift(b),a.validator.format.apply(this,c)}:void 0===c?b:(arguments.length>2&&c.constructor!==Array&&(c=a.makeArray(arguments).slice(1)),c.constructor!==Array&&(c=[c]),a.each(c,function(a,c){b=b.replace(new RegExp("\\{"+a+"\\}","g"),function(){return c})}),b)},a.extend(a.validator,{defaults:{messages:{},groups:{},rules:{},errorClass:"error",pendingClass:"pending",validClass:"valid",errorElement:"label",focusCleanup:!1,focusInvalid:!0,errorContainer:a([]),errorLabelContainer:a([]),onsubmit:!0,ignore:":hidden",ignoreTitle:!1,onfocusin:function(a){this.lastActive=a,this.settings.focusCleanup&&(this.settings.unhighlight&&this.settings.unhighlight.call(this,a,this.settings.errorClass,this.settings.validClass),this.hideThese(this.errorsFor(a)))},onfocusout:function(a){this.checkable(a)||!(a.name in this.submitted)&&this.optional(a)||this.element(a)},onkeyup:function(b,c){var d=[16,17,18,20,35,36,37,38,39,40,45,144,225];9===c.which&&""===this.elementValue(b)||a.inArray(c.keyCode,d)!==-1||(b.name in this.submitted||b.name in this.invalid)&&this.element(b)},onclick:function(a){a.name in this.submitted?this.element(a):a.parentNode.name in this.submitted&&this.element(a.parentNode)},highlight:function(b,c,d){"radio"===b.type?this.findByName(b.name).addClass(c).removeClass(d):a(b).addClass(c).removeClass(d)},unhighlight:function(b,c,d){"radio"===b.type?this.findByName(b.name).removeClass(c).addClass(d):a(b).removeClass(c).addClass(d)}},setDefaults:function(b){a.extend(a.validator.defaults,b)},messages:{required:"This field is required.",remote:"Please fix this field.",email:"Please enter a valid email address.",url:"Please enter a valid URL.",date:"Please enter a valid date.",dateISO:"Please enter a valid date (ISO).",number:"Please enter a valid number.",digits:"Please enter only digits.",equalTo:"Please enter the same value again.",maxlength:a.validator.format("Please enter no more than {0} characters."),minlength:a.validator.format("Please enter at least {0} characters."),rangelength:a.validator.format("Please enter a value between {0} and {1} characters long."),range:a.validator.format("Please enter a value between {0} and {1}."),max:a.validator.format("Please enter a value less than or equal to {0}."),min:a.validator.format("Please enter a value greater than or equal to {0}."),step:a.validator.format("Please enter a multiple of {0}.")},autoCreateRanges:!1,prototype:{init:function(){function b(b){var c="undefined"!=typeof a(this).attr("contenteditable")&&"false"!==a(this).attr("contenteditable");if(!this.form&&c&&(this.form=a(this).closest("form")[0],this.name=a(this).attr("name")),d===this.form){var e=a.data(this.form,"validator"),f="on"+b.type.replace(/^validate/,""),g=e.settings;g[f]&&!a(this).is(g.ignore)&&g[f].call(e,this,b)}}this.labelContainer=a(this.settings.errorLabelContainer),this.errorContext=this.labelContainer.length&&this.labelContainer||a(this.currentForm),this.containers=a(this.settings.errorContainer).add(this.settings.errorLabelContainer),this.submitted={},this.valueCache={},this.pendingRequest=0,this.pending={},this.invalid={},this.reset();var c,d=this.currentForm,e=this.groups={};a.each(this.settings.groups,function(b,c){"string"==typeof c&&(c=c.split(/\s/)),a.each(c,function(a,c){e[c]=b})}),c=this.settings.rules,a.each(c,function(b,d){c[b]=a.validator.normalizeRule(d)}),a(this.currentForm).on("focusin.validate focusout.validate keyup.validate",":text, [type='password'], [type='file'], select, textarea, [type='number'], [type='search'], [type='tel'], [type='url'], [type='email'], [type='datetime'], [type='date'], [type='month'], [type='week'], [type='time'], [type='datetime-local'], [type='range'], [type='color'], [type='radio'], [type='checkbox'], [contenteditable], [type='button']",b).on("click.validate","select, option, [type='radio'], [type='checkbox']",b),this.settings.invalidHandler&&a(this.currentForm).on("invalid-form.validate",this.settings.invalidHandler)},form:function(){return this.checkForm(),a.extend(this.submitted,this.errorMap),this.invalid=a.extend({},this.errorMap),this.valid()||a(this.currentForm).triggerHandler("invalid-form",[this]),this.showErrors(),this.valid()},checkForm:function(){this.prepareForm();for(var a=0,b=this.currentElements=this.elements();b[a];a++)this.check(b[a]);return this.valid()},element:function(b){var c,d,e=this.clean(b),f=this.validationTargetFor(e),g=this,h=!0;return void 0===f?delete this.invalid[e.name]:(this.prepareElement(f),this.currentElements=a(f),d=this.groups[f.name],d&&a.each(this.groups,function(a,b){b===d&&a!==f.name&&(e=g.validationTargetFor(g.clean(g.findByName(a))),e&&e.name in g.invalid&&(g.currentElements.push(e),h=g.check(e)&&h))}),c=this.check(f)!==!1,h=h&&c,c?this.invalid[f.name]=!1:this.invalid[f.name]=!0,this.numberOfInvalids()||(this.toHide=this.toHide.add(this.containers)),this.showErrors(),a(b).attr("aria-invalid",!c)),h},showErrors:function(b){if(b){var c=this;a.extend(this.errorMap,b),this.errorList=a.map(this.errorMap,function(a,b){return{message:a,element:c.findByName(b)[0]}}),this.successList=a.grep(this.successList,function(a){return!(a.name in b)})}this.settings.showErrors?this.settings.showErrors.call(this,this.errorMap,this.errorList):this.defaultShowErrors()},resetForm:function(){a.fn.resetForm&&a(this.currentForm).resetForm(),this.invalid={},this.submitted={},this.prepareForm(),this.hideErrors();var b=this.elements().removeData("previousValue").removeAttr("aria-invalid");this.resetElements(b)},resetElements:function(a){var b;if(this.settings.unhighlight)for(b=0;a[b];b++)this.settings.unhighlight.call(this,a[b],this.settings.errorClass,""),this.findByName(a[b].name).removeClass(this.settings.validClass);else a.removeClass(this.settings.errorClass).removeClass(this.settings.validClass)},numberOfInvalids:function(){return this.objectLength(this.invalid)},objectLength:function(a){var b,c=0;for(b in a)void 0!==a[b]&&null!==a[b]&&a[b]!==!1&&c++;return c},hideErrors:function(){this.hideThese(this.toHide)},hideThese:function(a){a.not(this.containers).text(""),this.addWrapper(a).hide()},valid:function(){return 0===this.size()},size:function(){return this.errorList.length},focusInvalid:function(){if(this.settings.focusInvalid)try{a(this.findLastActive()||this.errorList.length&&this.errorList[0].element||[]).filter(":visible").trigger("focus").trigger("focusin")}catch(b){}},findLastActive:function(){var b=this.lastActive;return b&&1===a.grep(this.errorList,function(a){return a.element.name===b.name}).length&&b},elements:function(){var b=this,c={};return a(this.currentForm).find("input, select, textarea, [contenteditable]").not(":submit, :reset, :image, :disabled").not(this.settings.ignore).filter(function(){var d=this.name||a(this).attr("name"),e="undefined"!=typeof a(this).attr("contenteditable")&&"false"!==a(this).attr("contenteditable");return!d&&b.settings.debug&&window.console&&console.error("%o has no name assigned",this),e&&(this.form=a(this).closest("form")[0],this.name=d),this.form===b.currentForm&&(!(d in c||!b.objectLength(a(this).rules()))&&(c[d]=!0,!0))})},clean:function(b){return a(b)[0]},errors:function(){var b=this.settings.errorClass.split(" ").join(".");return a(this.settings.errorElement+"."+b,this.errorContext)},resetInternals:function(){this.successList=[],this.errorList=[],this.errorMap={},this.toShow=a([]),this.toHide=a([])},reset:function(){this.resetInternals(),this.currentElements=a([])},prepareForm:function(){this.reset(),this.toHide=this.errors().add(this.containers)},prepareElement:function(a){this.reset(),this.toHide=this.errorsFor(a)},elementValue:function(b){var c,d,e=a(b),f=b.type,g="undefined"!=typeof e.attr("contenteditable")&&"false"!==e.attr("contenteditable");return"radio"===f||"checkbox"===f?this.findByName(b.name).filter(":checked").val():"number"===f&&"undefined"!=typeof b.validity?b.validity.badInput?"NaN":e.val():(c=g?e.text():e.val(),"file"===f?"C:\\fakepath\\"===c.substr(0,12)?c.substr(12):(d=c.lastIndexOf("/"),d>=0?c.substr(d+1):(d=c.lastIndexOf("\\"),d>=0?c.substr(d+1):c)):"string"==typeof c?c.replace(/\r/g,""):c)},check:function(b){b=this.validationTargetFor(this.clean(b));var c,d,e,f,g=a(b).rules(),h=a.map(g,function(a,b){return b}).length,i=!1,j=this.elementValue(b);"function"==typeof g.normalizer?f=g.normalizer:"function"==typeof this.settings.normalizer&&(f=this.settings.normalizer),f&&(j=f.call(b,j),delete g.normalizer);for(d in g){e={method:d,parameters:g[d]};try{if(c=a.validator.methods[d].call(this,j,b,e.parameters),"dependency-mismatch"===c&&1===h){i=!0;continue}if(i=!1,"pending"===c)return void(this.toHide=this.toHide.not(this.errorsFor(b)));if(!c)return this.formatAndAdd(b,e),!1}catch(k){throw this.settings.debug&&window.console&&console.log("Exception occurred when checking element "+b.id+", check the '"+e.method+"' method.",k),k instanceof TypeError&&(k.message+=". Exception occurred when checking element "+b.id+", check the '"+e.method+"' method."),k}}if(!i)return this.objectLength(g)&&this.successList.push(b),!0},customDataMessage:function(b,c){return a(b).data("msg"+c.charAt(0).toUpperCase()+c.substring(1).toLowerCase())||a(b).data("msg")},customMessage:function(a,b){var c=this.settings.messages[a];return c&&(c.constructor===String?c:c[b])},findDefined:function(){for(var a=0;aWarning: No message defined for "+b.name+""),e=/\$?\{(\d+)\}/g;return"function"==typeof d?d=d.call(this,c.parameters,b):e.test(d)&&(d=a.validator.format(d.replace(e,"{$1}"),c.parameters)),d},formatAndAdd:function(a,b){var c=this.defaultMessage(a,b);this.errorList.push({message:c,element:a,method:b.method}),this.errorMap[a.name]=c,this.submitted[a.name]=c},addWrapper:function(a){return this.settings.wrapper&&(a=a.add(a.parent(this.settings.wrapper))),a},defaultShowErrors:function(){var a,b,c;for(a=0;this.errorList[a];a++)c=this.errorList[a],this.settings.highlight&&this.settings.highlight.call(this,c.element,this.settings.errorClass,this.settings.validClass),this.showLabel(c.element,c.message);if(this.errorList.length&&(this.toShow=this.toShow.add(this.containers)),this.settings.success)for(a=0;this.successList[a];a++)this.showLabel(this.successList[a]);if(this.settings.unhighlight)for(a=0,b=this.validElements();b[a];a++)this.settings.unhighlight.call(this,b[a],this.settings.errorClass,this.settings.validClass);this.toHide=this.toHide.not(this.toShow),this.hideErrors(),this.addWrapper(this.toShow).show()},validElements:function(){return this.currentElements.not(this.invalidElements())},invalidElements:function(){return a(this.errorList).map(function(){return this.element})},showLabel:function(b,c){var d,e,f,g,h=this.errorsFor(b),i=this.idOrName(b),j=a(b).attr("aria-describedby");h.length?(h.removeClass(this.settings.validClass).addClass(this.settings.errorClass),h.html(c)):(h=a("<"+this.settings.errorElement+">").attr("id",i+"-error").addClass(this.settings.errorClass).html(c||""),d=h,this.settings.wrapper&&(d=h.hide().show().wrap("<"+this.settings.wrapper+"/>").parent()),this.labelContainer.length?this.labelContainer.append(d):this.settings.errorPlacement?this.settings.errorPlacement.call(this,d,a(b)):d.insertAfter(b),h.is("label")?h.attr("for",i):0===h.parents("label[for='"+this.escapeCssMeta(i)+"']").length&&(f=h.attr("id"),j?j.match(new RegExp("\\b"+this.escapeCssMeta(f)+"\\b"))||(j+=" "+f):j=f,a(b).attr("aria-describedby",j),e=this.groups[b.name],e&&(g=this,a.each(g.groups,function(b,c){c===e&&a("[name='"+g.escapeCssMeta(b)+"']",g.currentForm).attr("aria-describedby",h.attr("id"))})))),!c&&this.settings.success&&(h.text(""),"string"==typeof this.settings.success?h.addClass(this.settings.success):this.settings.success(h,b)),this.toShow=this.toShow.add(h)},errorsFor:function(b){var c=this.escapeCssMeta(this.idOrName(b)),d=a(b).attr("aria-describedby"),e="label[for='"+c+"'], label[for='"+c+"'] *";return d&&(e=e+", #"+this.escapeCssMeta(d).replace(/\s+/g,", #")),this.errors().filter(e)},escapeCssMeta:function(a){return a.replace(/([\\!"#$%&'()*+,.\/:;<=>?@\[\]^`{|}~])/g,"\\$1")},idOrName:function(a){return this.groups[a.name]||(this.checkable(a)?a.name:a.id||a.name)},validationTargetFor:function(b){return this.checkable(b)&&(b=this.findByName(b.name)),a(b).not(this.settings.ignore)[0]},checkable:function(a){return/radio|checkbox/i.test(a.type)},findByName:function(b){return a(this.currentForm).find("[name='"+this.escapeCssMeta(b)+"']")},getLength:function(b,c){switch(c.nodeName.toLowerCase()){case"select":return a("option:selected",c).length;case"input":if(this.checkable(c))return this.findByName(c.name).filter(":checked").length}return b.length},depend:function(a,b){return!this.dependTypes[typeof a]||this.dependTypes[typeof a](a,b)},dependTypes:{"boolean":function(a){return a},string:function(b,c){return!!a(b,c.form).length},"function":function(a,b){return a(b)}},optional:function(b){var c=this.elementValue(b);return!a.validator.methods.required.call(this,c,b)&&"dependency-mismatch"},startRequest:function(b){this.pending[b.name]||(this.pendingRequest++,a(b).addClass(this.settings.pendingClass),this.pending[b.name]=!0)},stopRequest:function(b,c){this.pendingRequest--,this.pendingRequest<0&&(this.pendingRequest=0),delete this.pending[b.name],a(b).removeClass(this.settings.pendingClass),c&&0===this.pendingRequest&&this.formSubmitted&&this.form()?(a(this.currentForm).submit(),this.submitButton&&a("input:hidden[name='"+this.submitButton.name+"']",this.currentForm).remove(),this.formSubmitted=!1):!c&&0===this.pendingRequest&&this.formSubmitted&&(a(this.currentForm).triggerHandler("invalid-form",[this]),this.formSubmitted=!1)},previousValue:function(b,c){return c="string"==typeof c&&c||"remote",a.data(b,"previousValue")||a.data(b,"previousValue",{old:null,valid:!0,message:this.defaultMessage(b,{method:c})})},destroy:function(){this.resetForm(),a(this.currentForm).off(".validate").removeData("validator").find(".validate-equalTo-blur").off(".validate-equalTo").removeClass("validate-equalTo-blur").find(".validate-lessThan-blur").off(".validate-lessThan").removeClass("validate-lessThan-blur").find(".validate-lessThanEqual-blur").off(".validate-lessThanEqual").removeClass("validate-lessThanEqual-blur").find(".validate-greaterThanEqual-blur").off(".validate-greaterThanEqual").removeClass("validate-greaterThanEqual-blur").find(".validate-greaterThan-blur").off(".validate-greaterThan").removeClass("validate-greaterThan-blur")}},classRuleSettings:{required:{required:!0},email:{email:!0},url:{url:!0},date:{date:!0},dateISO:{dateISO:!0},number:{number:!0},digits:{digits:!0},creditcard:{creditcard:!0}},addClassRules:function(b,c){b.constructor===String?this.classRuleSettings[b]=c:a.extend(this.classRuleSettings,b)},classRules:function(b){var c={},d=a(b).attr("class");return d&&a.each(d.split(" "),function(){this in a.validator.classRuleSettings&&a.extend(c,a.validator.classRuleSettings[this])}),c},normalizeAttributeRule:function(a,b,c,d){/min|max|step/.test(c)&&(null===b||/number|range|text/.test(b))&&(d=Number(d),isNaN(d)&&(d=void 0)),d||0===d?a[c]=d:b===c&&"range"!==b&&(a[c]=!0)},attributeRules:function(b){var c,d,e={},f=a(b),g=b.getAttribute("type");for(c in a.validator.methods)"required"===c?(d=b.getAttribute(c),""===d&&(d=!0),d=!!d):d=f.attr(c),this.normalizeAttributeRule(e,g,c,d);return e.maxlength&&/-1|2147483647|524288/.test(e.maxlength)&&delete e.maxlength,e},dataRules:function(b){var c,d,e={},f=a(b),g=b.getAttribute("type");for(c in a.validator.methods)d=f.data("rule"+c.charAt(0).toUpperCase()+c.substring(1).toLowerCase()),""===d&&(d=!0),this.normalizeAttributeRule(e,g,c,d);return e},staticRules:function(b){var c={},d=a.data(b.form,"validator");return d.settings.rules&&(c=a.validator.normalizeRule(d.settings.rules[b.name])||{}),c},normalizeRules:function(b,c){return a.each(b,function(d,e){if(e===!1)return void delete b[d];if(e.param||e.depends){var f=!0;switch(typeof e.depends){case"string":f=!!a(e.depends,c.form).length;break;case"function":f=e.depends.call(c,c)}f?b[d]=void 0===e.param||e.param:(a.data(c.form,"validator").resetElements(a(c)),delete b[d])}}),a.each(b,function(a,d){b[a]="function"==typeof d&&"normalizer"!==a?d(c):d}),a.each(["minlength","maxlength"],function(){b[this]&&(b[this]=Number(b[this]))}),a.each(["rangelength","range"],function(){var a;b[this]&&(Array.isArray(b[this])?b[this]=[Number(b[this][0]),Number(b[this][1])]:"string"==typeof b[this]&&(a=b[this].replace(/[\[\]]/g,"").split(/[\s,]+/),b[this]=[Number(a[0]),Number(a[1])]))}),a.validator.autoCreateRanges&&(null!=b.min&&null!=b.max&&(b.range=[b.min,b.max],delete b.min,delete b.max),null!=b.minlength&&null!=b.maxlength&&(b.rangelength=[b.minlength,b.maxlength],delete b.minlength,delete b.maxlength)),b},normalizeRule:function(b){if("string"==typeof b){var c={};a.each(b.split(/\s/),function(){c[this]=!0}),b=c}return b},addMethod:function(b,c,d){a.validator.methods[b]=c,a.validator.messages[b]=void 0!==d?d:a.validator.messages[b],c.length<3&&a.validator.addClassRules(b,a.validator.normalizeRule(b))},methods:{required:function(b,c,d){if(!this.depend(d,c))return"dependency-mismatch";if("select"===c.nodeName.toLowerCase()){var e=a(c).val();return e&&e.length>0}return this.checkable(c)?this.getLength(b,c)>0:void 0!==b&&null!==b&&b.length>0},email:function(a,b){return this.optional(b)||/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(a)},url:function(a,b){return this.optional(b)||/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[\/?#]\S*)?$/i.test(a)},date:function(){var a=!1;return function(b,c){return a||(a=!0,this.settings.debug&&window.console&&console.warn("The `date` method is deprecated and will be removed in version '2.0.0'.\nPlease don't use it, since it relies on the Date constructor, which\nbehaves very differently across browsers and locales. Use `dateISO`\ninstead or one of the locale specific methods in `localizations/`\nand `additional-methods.js`.")),this.optional(c)||!/Invalid|NaN/.test(new Date(b).toString())}}(),dateISO:function(a,b){return this.optional(b)||/^\d{4}[\/\-](0?[1-9]|1[012])[\/\-](0?[1-9]|[12][0-9]|3[01])$/.test(a)},number:function(a,b){return this.optional(b)||/^(?:-?\d+|-?\d{1,3}(?:,\d{3})+)?(?:\.\d+)?$/.test(a)},digits:function(a,b){return this.optional(b)||/^\d+$/.test(a)},minlength:function(a,b,c){var d=Array.isArray(a)?a.length:this.getLength(a,b);return this.optional(b)||d>=c},maxlength:function(a,b,c){var d=Array.isArray(a)?a.length:this.getLength(a,b);return this.optional(b)||d<=c},rangelength:function(a,b,c){var d=Array.isArray(a)?a.length:this.getLength(a,b);return this.optional(b)||d>=c[0]&&d<=c[1]},min:function(a,b,c){return this.optional(b)||a>=c},max:function(a,b,c){return this.optional(b)||a<=c},range:function(a,b,c){return this.optional(b)||a>=c[0]&&a<=c[1]},step:function(b,c,d){var e,f=a(c).attr("type"),g="Step attribute on input type "+f+" is not supported.",h=["text","number","range"],i=new RegExp("\\b"+f+"\\b"),j=f&&!i.test(h.join()),k=function(a){var b=(""+a).match(/(?:\.(\d+))?$/);return b&&b[1]?b[1].length:0},l=function(a){return Math.round(a*Math.pow(10,e))},m=!0;if(j)throw new Error(g);return e=k(d),(k(b)>e||l(b)%l(d)!==0)&&(m=!1),this.optional(c)||m},equalTo:function(b,c,d){var e=a(d);return this.settings.onfocusout&&e.not(".validate-equalTo-blur").length&&e.addClass("validate-equalTo-blur").on("blur.validate-equalTo",function(){a(c).valid()}),b===e.val()},remote:function(b,c,d,e){if(this.optional(c))return"dependency-mismatch";e="string"==typeof e&&e||"remote";var f,g,h,i=this.previousValue(c,e);return this.settings.messages[c.name]||(this.settings.messages[c.name]={}),i.originalMessage=i.originalMessage||this.settings.messages[c.name][e],this.settings.messages[c.name][e]=i.message,d="string"==typeof d&&{url:d}||d,h=a.param(a.extend({data:b},d.data)),i.old===h?i.valid:(i.old=h,f=this,this.startRequest(c),g={},g[c.name]=b,a.ajax(a.extend(!0,{mode:"abort",port:"validate"+c.name,dataType:"json",data:g,context:f.currentForm,success:function(a){var d,g,h,j=a===!0||"true"===a;f.settings.messages[c.name][e]=i.originalMessage,j?(h=f.formSubmitted,f.resetInternals(),f.toHide=f.errorsFor(c),f.formSubmitted=h,f.successList.push(c),f.invalid[c.name]=!1,f.showErrors()):(d={},g=a||f.defaultMessage(c,{method:e,parameters:b}),d[c.name]=i.message=g,f.invalid[c.name]=!0,f.showErrors(d)),i.valid=j,f.stopRequest(c,j)}},d)),"pending")}}});var c,d={};return a.ajaxPrefilter?a.ajaxPrefilter(function(a,b,c){var e=a.port;"abort"===a.mode&&(d[e]&&d[e].abort(),d[e]=c)}):(c=a.ajax,a.ajax=function(b){var e=("mode"in b?b:a.ajaxSettings).mode,f=("port"in b?b:a.ajaxSettings).port;return"abort"===e?(d[f]&&d[f].abort(),d[f]=c.apply(this,arguments),d[f]):c.apply(this,arguments)}),a}); \ No newline at end of file diff --git a/templates/accounts/customer/account_transfer_email_template.html b/templates/accounts/customer/account_transfer_email_template.html new file mode 100644 index 0000000..42fea12 --- /dev/null +++ b/templates/accounts/customer/account_transfer_email_template.html @@ -0,0 +1,31 @@ + + + + Your Exclusive Account Access Details with Good Times! + + + +

Dear Valued Customer,

+

Greetings from Good Times! We trust this correspondence finds you in splendid spirits.

+

We are pleased to provide you with your account credentials for seamless access:

+ + + + + + + + + +
Username:{{ principal_obj.email }}
Password:{{ temp_password }}
+

Please utilize the temporary password to access your account promptly. Upon your initial login, we recommend changing your password to further enhance security measures.

+

We sincerely hope your experience with Good Times has been delightful thus far and look forward to continuing to exceed your expectations!

+

Warmest regards,

+ Good Times
+ {{ settings.DEFAULT_FROM_EMAIL }}

+ + \ No newline at end of file diff --git a/templates/accounts/customer/customer_add.html b/templates/accounts/customer/customer_add.html new file mode 100644 index 0000000..d147cf1 --- /dev/null +++ b/templates/accounts/customer/customer_add.html @@ -0,0 +1,282 @@ +{% extends 'layout/base_template.html' %} +{% load static %} +{% block stylesheet %} + + +{% include "cdn_through_html/flatpicker_cdn_css.html" %} +{% include "cdn_through_html/filepond_cdn_css.html" %} + + + +{% endblock %} + +{% block content %} + +
+
+ +
+
+
+
+ +
+ {% csrf_token %} + {% include 'includes/dynamic_template_form.html' with form=form %} +
+
+
+
+ +
+
+
+
+
+
+ + +{% endblock content %} + +{% block javascript %} + +{% include "cdn_through_html/filepond_cdn_js.html" %} +{% include "cdn_through_html/flatpicker_cdn_js.html" %} +{% include "cdn_through_html/jquery_validate_cdn_js.html" %} + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/accounts/customer/customer_bulk_template.html b/templates/accounts/customer/customer_bulk_template.html new file mode 100644 index 0000000..4b45115 --- /dev/null +++ b/templates/accounts/customer/customer_bulk_template.html @@ -0,0 +1,80 @@ +{% extends 'layout/base_template.html' %} +{% load static %} +{% block stylesheet %} + +{% endblock %} + +{% block content %} + +
+
+ +
+
+
+
+ +
+ {% csrf_token %} + {% include 'includes/dynamic_template_form.html' with form=form %} +
+
+
+
+ +
+ {% if error_log %} +

Error Log:

+
    + {% for error in error_log %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ +
+
+
+
+
+
+ + +{% endblock content %} + +{% block javascript %} + + +{% endblock %} \ No newline at end of file diff --git a/templates/accounts/customer/customer_detail.html b/templates/accounts/customer/customer_detail.html new file mode 100644 index 0000000..3fc96ca --- /dev/null +++ b/templates/accounts/customer/customer_detail.html @@ -0,0 +1,123 @@ +{% extends 'layout/base_template.html' %} +{% load static %} +{% block stylesheet %} + + +{% endblock %} + +{% block content %} + +
+
+ + + +
+
+
+
+
+ +
First Name
+ +
{{principal_obj.first_name}}
+ +
+
+
Last Name
+
{{principal_obj.last_name}}
+
+
+
Business Name
+
{{principal_obj.business_name}}
+
+
+
Email Address
+
{{principal_obj.email}}
+
+
+
Phone No
+
{{principal_obj.phone_no}}
+
+
+
Preferences
+
+ {% for category in principal_preference.preferred_categories.all %} + + {{ category.title }} + + {% empty %} + + No preferred categories. + + {% endfor %} +
+
+
+
Start Date
+
{% if principal_subscription %}{{ principal_subscription.start_date }}{% else %}No subscription found{% endif %}
+
+
+
End Date
+
{% if principal_subscription %}{{ principal_subscription.end_date }}{% else %}No subscription found{% endif %}
+
+
+
Address
+
{{principal_obj.address_line1}}
+
+
+
Region
+
{{principal_obj.city}}
+
+
+
Country
+
{{principal_obj.country}}
+
+
+
Website
+
{{principal_obj.website}}
+
+
+
Facebook
+
{{principal_obj.facebook_profile}}
+
+
+
LinkedIn
+
{{principal_obj.linkedin_profile}}
+
+
+
Instagram
+
{{principal_obj.instagram_profile}}
+
+
+
Twitter
+
{{principal_obj.twitter_profile}}
+
+ {% if principal_obj.extended_data and not principal_obj.extended_data.is_transferred and principal_obj.extended_data.is_onboarded and principal_obj.principal_type.name == 'event_manager' %} + + {% endif %} +
+
+
+
+
+
+ + + {% endblock content %} + + {% block javascript %} + + {% endblock %} \ No newline at end of file diff --git a/templates/accounts/customer/customer_edit.html b/templates/accounts/customer/customer_edit.html new file mode 100644 index 0000000..a764f5e --- /dev/null +++ b/templates/accounts/customer/customer_edit.html @@ -0,0 +1,287 @@ +{% extends 'layout/base_template.html' %} +{% load static %} +{% block stylesheet %} + + + {% include "cdn_through_html/flatpicker_cdn_css.html" %} + {% include "cdn_through_html/filepond_cdn_css.html" %} + + +{% endblock %} + +{% block content %} + +
+
+
+ + + {% if principal_obj.extended_data and not principal_obj.extended_data.is_transferred and principal_obj.extended_data.is_onboarded and principal_obj.principal_type.name == 'event_manager' %} + + {% endif %} + +
+
+
+
+
+ +
+ {% csrf_token %} + {% include 'includes/dynamic_template_form.html' with form=form %} +
+
+
+
+ +
+
+
+
+
+
+ + +{% endblock content %} + +{% block javascript %} + + {% include "cdn_through_html/filepond_cdn_js.html" %} + {% include "cdn_through_html/flatpicker_cdn_js.html" %} + {% include "cdn_through_html/jquery_validate_cdn_js.html" %} + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/accounts/customer/customer_list.html b/templates/accounts/customer/customer_list.html index 442c97d..fdb7dbe 100644 --- a/templates/accounts/customer/customer_list.html +++ b/templates/accounts/customer/customer_list.html @@ -1,8 +1,10 @@ {% extends 'layout/base_template.html' %} {% load static %} {% block stylesheet %} - - {% include "cdn_through_html/datatable_cdn_css.html" %} + +{% include "cdn_through_html/datatable_cdn_css.html" %} +{% include "cdn_through_html/modal_cdn_css.html" %} +{% include "cdn_through_html/sweetalert2_cdn_css.html" %} {% endblock %} @@ -11,22 +13,28 @@
-
+

Manage Customer

- - - + +
@@ -40,57 +48,93 @@ Record Id - Image - First Name - Last Name - Email - - Principal Type + Image + First Name + Last Name + Email + # + Principal Type - Email Verified - Referral Count - Created On - Modified On - Active + Email Verified + Referral Count + Onboarded by Admin + Transferred to Customer + + Created On + Modified On + Active - Action + Action {% for data_obj in data_objs %} - {{ data_obj.id }} + {{ data_obj.id }} - avatar + + + {{ data_obj.first_name }} {{ data_obj.last_name }} {{ data_obj.email }} - + + {% if data_obj.extended_data and data_obj.extended_data.is_onboarded and not data_obj.extended_data.is_transferred %} + + {% endif %} + {{ data_obj.principal_type.name }} {{ data_obj.email_verified }} {{ data_obj.referral_count }} + + + {% if data_obj.extended_data %} + {{ data_obj.extended_data.is_onboarded }} + {% else %} + False + {% endif %} + + + + + {% if data_obj.extended_data %} + {{ data_obj.extended_data.is_transferred }} + {% else %} + False + {% endif %} + + {{ data_obj.created_on }} {{ data_obj.modified_on }} - + {{ data_obj.is_active }} @@ -101,11 +145,12 @@ --> @@ -141,31 +187,89 @@
+ + + + {% endblock content %} {% block javascript %} - - {% include "cdn_through_html/datatable_cdn_js.html" %} - - + {% endblock %} \ No newline at end of file diff --git a/templates/accounts/customer/customer_manager_edit.html b/templates/accounts/customer/customer_manager_edit.html new file mode 100644 index 0000000..ccf62ef --- /dev/null +++ b/templates/accounts/customer/customer_manager_edit.html @@ -0,0 +1,162 @@ +{% extends 'layout/base_template.html' %} +{% load static %} +{% block stylesheet %} + + + {% include "cdn_through_html/flatpicker_cdn_css.html" %} +{% endblock %} + +{% block content %} + +
+
+ +
+
+
+
+ +
+ {% csrf_token %} + {% include 'includes/dynamic_template_form.html' with form=form %} +
+
+
+
+ +
+
+
+
+
+
+ + +{% endblock content %} + +{% block javascript %} + + {% include "cdn_through_html/flatpicker_cdn_js.html" %} + {% include "cdn_through_html/jquery_validate_cdn_js.html" %} + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/accounts/customer/customer_onboard_manager_edit.html b/templates/accounts/customer/customer_onboard_manager_edit.html new file mode 100644 index 0000000..7a45364 --- /dev/null +++ b/templates/accounts/customer/customer_onboard_manager_edit.html @@ -0,0 +1,206 @@ +{% extends 'layout/base_template.html' %} +{% load static %} +{% block stylesheet %} + + + {% include "cdn_through_html/flatpicker_cdn_css.html" %} +{% endblock %} + +{% block content %} + +
+
+
+ + + {% if principal_obj.extended_data and not principal_obj.extended_data.is_transferred and principal_obj.extended_data.is_onboarded and principal_obj.principal_type.name == 'event_manager' %} + + {% endif %} + +
+
+
+
+
+ +
+ {% csrf_token %} + {% include 'includes/dynamic_template_form.html' with form=form %} +
+
+
+
+ +
+
+
+
+
+
+ + +{% endblock content %} + +{% block javascript %} + + {% include "cdn_through_html/flatpicker_cdn_js.html" %} + {% include "cdn_through_html/jquery_validate_cdn_js.html" %} + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/accounts/customer/customer_user_edit.html b/templates/accounts/customer/customer_user_edit.html new file mode 100644 index 0000000..44ac214 --- /dev/null +++ b/templates/accounts/customer/customer_user_edit.html @@ -0,0 +1,153 @@ +{% extends 'layout/base_template.html' %} +{% load static %} +{% block stylesheet %} + + + {% include "cdn_through_html/flatpicker_cdn_css.html" %} +{% endblock %} + +{% block content %} + +
+
+ +
+
+
+
+ +
+ {% csrf_token %} + {% include 'includes/dynamic_template_form.html' with form=form %} +
+
+
+
+ +
+
+
+
+
+
+ + +{% endblock content %} + +{% block javascript %} + + {% include "cdn_through_html/flatpicker_cdn_js.html" %} + {% include "cdn_through_html/jquery_validate_cdn_js.html" %} + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/cdn_through_html/jquery_validate_cdn_js.html b/templates/cdn_through_html/jquery_validate_cdn_js.html new file mode 100644 index 0000000..96f8d24 --- /dev/null +++ b/templates/cdn_through_html/jquery_validate_cdn_js.html @@ -0,0 +1,2 @@ +{% load static%} + \ No newline at end of file diff --git a/templates/cdn_through_html/sweetalert2_cdn_css.html b/templates/cdn_through_html/sweetalert2_cdn_css.html new file mode 100644 index 0000000..01bab28 --- /dev/null +++ b/templates/cdn_through_html/sweetalert2_cdn_css.html @@ -0,0 +1,3 @@ +{% load static%} + + diff --git a/templates/cdn_through_html/sweetalert2_cdn_js.html b/templates/cdn_through_html/sweetalert2_cdn_js.html new file mode 100644 index 0000000..00a21b9 --- /dev/null +++ b/templates/cdn_through_html/sweetalert2_cdn_js.html @@ -0,0 +1,3 @@ +{% load static%} + + \ No newline at end of file diff --git a/templates/elements/sidebar.html b/templates/elements/sidebar.html index bd845d0..4e9564a 100644 --- a/templates/elements/sidebar.html +++ b/templates/elements/sidebar.html @@ -154,6 +154,17 @@ {% endif %} + {% comment %} {% if user|has_resource_permission:resource_context.RESOURCE_MANAGE_COUPONS %} + + {% endif %} {% endcomment %} {% if user|has_resource_permission:resource_context.RESOURCE_PRINCIPAL_SUBSCRIPTIONS %}