diff --git a/.claude/worktrees/strange-ellis b/.claude/worktrees/strange-ellis new file mode 160000 index 0000000..88b3aaf --- /dev/null +++ b/.claude/worktrees/strange-ellis @@ -0,0 +1 @@ +Subproject commit 88b3aafb0b5e13a33af2f37957a0c3091057fc5d diff --git a/accounts/api.py b/accounts/api.py new file mode 100644 index 0000000..0edc10d --- /dev/null +++ b/accounts/api.py @@ -0,0 +1,946 @@ +from django.http import JsonResponse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.forms.models import model_to_dict +from django.contrib.auth import authenticate, logout +from rest_framework.views import APIView +from rest_framework.authtoken.models import Token +import json + +from .models import User +from mobile_api.utils import validate_token_and_get_user + + +def _partner_user_to_dict(user, request=None): + """Serialize partner-related User for JSON (same structure as _user_to_dict).""" + data = model_to_dict( + user, + fields=[ + "id", + "username", + "email", + "phone_number", + "role", + "is_staff", + "is_customer", + "is_user", + "pincode", + "district", + "state", + "country", + "place", + "latitude", + "longitude", + "first_name", + "last_name", + ], + ) + # Add profile picture URL if exists + if getattr(user, "profile_picture", None): + if request: + data["profile_picture"] = request.build_absolute_uri(user.profile_picture.url) + else: + data["profile_picture"] = user.profile_picture.url + else: + data["profile_picture"] = None + return data + + +def _user_to_dict(user, request=None): + """Serialize any User for JSON (admin/staff or partner).""" + return _partner_user_to_dict(user, request) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerLoginAPI(APIView): + """ + Partner Login API. + Body: username (or email), password (required). + Returns: token, user data. + """ + + def post(self, request): + try: + # Parse JSON or form data + is_multipart = request.content_type and "multipart/form-data" in request.content_type + if is_multipart: + data = request.POST.dict() + else: + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse( + {"status": "error", "message": "Invalid JSON"}, + status=400, + ) + + username = data.get("username") or data.get("email") + password = data.get("password") + + if not username or not password: + return JsonResponse( + {"status": "error", "message": "username and password are required."}, + status=400, + ) + + # Authenticate user + user = authenticate(request, username=username, password=password) + if not user: + return JsonResponse( + {"status": "error", "message": "Invalid username or password."}, + status=401, + ) + + # Check if user has partner role + partner_roles = ["partner", "partner_manager", "partner_staff"] + if user.role not in partner_roles: + return JsonResponse( + {"status": "error", "message": "You are not authorized to access partner portal."}, + status=403, + ) + + # Get or create token + token, _ = Token.objects.get_or_create(user=user) + + return JsonResponse( + { + "status": "success", + "message": "Login successful", + "token": token.key, + "user": _partner_user_to_dict(user, request), + }, + status=200, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerLogoutAPI(APIView): + """ + Partner Logout API. + Body: token, username (required). + Returns: success message. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + # Check if user has partner role + partner_roles = ["partner", "partner_manager", "partner_staff"] + if user.role not in partner_roles: + return JsonResponse( + {"status": "error", "message": "You are not authorized to access partner portal."}, + status=403, + ) + + # Delete token + token.delete() + + return JsonResponse( + { + "status": "success", + "message": "Logged out successfully.", + }, + status=200, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerDashboardAPI(APIView): + """ + Partner Dashboard API. + Body: token, username (required). + Returns: dashboard statistics. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + # Check if user has partner role + partner_roles = ["partner", "partner_manager", "partner_staff"] + if user.role not in partner_roles: + return JsonResponse( + {"status": "error", "message": "You are not authorized to access this page."}, + status=403, + ) + + # Get statistics for partner users (including partner_customer) + all_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"] + partner_users = User.objects.filter(role__in=all_partner_roles) + total_partner_users = partner_users.count() + + return JsonResponse( + { + "status": "success", + "dashboard": { + "total_partner_users": total_partner_users, + }, + }, + status=200, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerListUsersAPI(APIView): + """ + Partner List Users API. + Body: token, username (required); + role (optional filter: partner, partner_manager, partner_staff, partner_customer). + Returns: list of partner users. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + # Check if user has partner role + partner_roles = ["partner", "partner_manager", "partner_staff"] + if user.role not in partner_roles: + return JsonResponse( + {"status": "error", "message": "You are not authorized to access this page."}, + status=403, + ) + + # Filter users by partner-related roles + all_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"] + qs = User.objects.filter(role__in=all_partner_roles).order_by("-id") + + # Optional role filter + role_filter = data.get("role") + if role_filter: + if role_filter not in all_partner_roles: + return JsonResponse( + { + "status": "error", + "message": f"Invalid role filter. Must be one of: {', '.join(all_partner_roles)}", + }, + status=400, + ) + qs = qs.filter(role=role_filter) + + users = [_partner_user_to_dict(u, request) for u in qs] + + return JsonResponse( + { + "status": "success", + "users": users, + "total_count": len(users), + }, + status=200, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerCreateUserAPI(APIView): + """ + Partner Create User API. + Body: token, username, username (for new user), email, password, role (required); + full_name, phone_number, pincode, district, state, country, place, latitude, longitude (optional). + Returns: created user data. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + # Check if user has partner role + partner_roles = ["partner", "partner_manager", "partner_staff"] + if user.role not in partner_roles: + return JsonResponse( + {"status": "error", "message": "You are not authorized to access this page."}, + status=403, + ) + + # Extract user data + new_username = data.get("username") + email = data.get("email") + password = data.get("password") + role = data.get("role") + full_name = data.get("full_name", "").strip() + + if not all([new_username, email, password, role]): + return JsonResponse( + { + "status": "error", + "message": "username, email, password, and role are required.", + }, + status=400, + ) + + # Validate role - must be one of the partner-related roles + valid_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"] + if role not in valid_partner_roles: + return JsonResponse( + { + "status": "error", + "message": f"Invalid role. Must be one of: {', '.join(valid_partner_roles)}", + }, + status=400, + ) + + # Check if username already exists + if User.objects.filter(username=new_username).exists(): + return JsonResponse( + {"status": "error", "message": "Username already exists."}, + status=400, + ) + + # Check if email already exists + if User.objects.filter(email=email).exists(): + return JsonResponse( + {"status": "error", "message": "Email already exists."}, + status=400, + ) + + # Create user + new_user = User.objects.create_user( + username=new_username, + email=email, + password=password, + role=role, + phone_number=data.get("phone_number"), + pincode=data.get("pincode"), + district=data.get("district"), + state=data.get("state"), + country=data.get("country"), + place=data.get("place"), + ) + + # Handle full_name - split into first_name and last_name + if full_name: + parts = full_name.split(None, 1) + new_user.first_name = parts[0] + if len(parts) > 1: + new_user.last_name = parts[1] + + # Set location coordinates if provided + if data.get("latitude") is not None: + try: + latitude = float(data["latitude"]) + if latitude < -90 or latitude > 90: + return JsonResponse( + {"status": "error", "message": "latitude must be between -90 and 90."}, + status=400, + ) + new_user.latitude = latitude + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "latitude must be numeric."}, + status=400, + ) + + if data.get("longitude") is not None: + try: + longitude = float(data["longitude"]) + if longitude < -180 or longitude > 180: + return JsonResponse( + {"status": "error", "message": "longitude must be between -180 and 180."}, + status=400, + ) + new_user.longitude = longitude + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "longitude must be numeric."}, + status=400, + ) + + # Handle profile picture upload if provided + if "profile_picture" in request.FILES: + new_user.profile_picture = request.FILES["profile_picture"] + + new_user.save() + + return JsonResponse( + { + "status": "success", + "message": f"User created successfully with role: {role}.", + "user": _partner_user_to_dict(new_user, request), + }, + status=201, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerUpdateUserAPI(APIView): + """ + Partner Update User API. + Body: token, username, user_id (required); + email, phone_number, role, full_name, pincode, district, state, + country, place, latitude, longitude, password, profile_picture (optional). + Returns: updated user data. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + # Check if user has partner role + partner_roles = ["partner", "partner_manager", "partner_staff"] + if user.role not in partner_roles: + return JsonResponse( + {"status": "error", "message": "You are not authorized to access this page."}, + status=403, + ) + + user_id = data.get("user_id") + if not user_id: + return JsonResponse( + {"status": "error", "message": "user_id is required."}, + status=400, + ) + + try: + target_user = User.objects.get(id=user_id) + except User.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "User not found."}, + status=404, + ) + + # Validate that the user has a partner-related role + all_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"] + if target_user.role not in all_partner_roles: + return JsonResponse( + { + "status": "error", + "message": "User is not a partner-related user. Only users with partner roles can be updated.", + }, + status=400, + ) + + # Update fields if provided + if data.get("email") is not None: + new_email = data["email"] + # Check if email already exists for another user + if User.objects.filter(email=new_email).exclude(id=user_id).exists(): + return JsonResponse( + {"status": "error", "message": "Email already exists for another user."}, + status=400, + ) + target_user.email = new_email + + if data.get("phone_number") is not None: + target_user.phone_number = data["phone_number"] or None + + if data.get("role") is not None: + new_role = data["role"] + if new_role not in all_partner_roles: + return JsonResponse( + { + "status": "error", + "message": f"Invalid role. Must be one of: {', '.join(all_partner_roles)}", + }, + status=400, + ) + target_user.role = new_role + + # Handle full_name + if data.get("full_name"): + full_name = data["full_name"].strip() + if full_name: + parts = full_name.split(None, 1) + target_user.first_name = parts[0] + if len(parts) > 1: + target_user.last_name = parts[1] + else: + target_user.last_name = "" + + if "pincode" in data: + target_user.pincode = data["pincode"] or None + if "district" in data: + target_user.district = data["district"] or None + if "state" in data: + target_user.state = data["state"] or None + if "country" in data: + target_user.country = data["country"] or None + if "place" in data: + target_user.place = data["place"] or None + + if data.get("latitude") is not None: + try: + latitude = float(data["latitude"]) + if latitude < -90 or latitude > 90: + return JsonResponse( + {"status": "error", "message": "latitude must be between -90 and 90."}, + status=400, + ) + target_user.latitude = latitude + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "latitude must be numeric."}, + status=400, + ) + + if data.get("longitude") is not None: + try: + longitude = float(data["longitude"]) + if longitude < -180 or longitude > 180: + return JsonResponse( + {"status": "error", "message": "longitude must be between -180 and 180."}, + status=400, + ) + target_user.longitude = longitude + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "longitude must be numeric."}, + status=400, + ) + + # Handle profile picture upload if provided + if "profile_picture" in request.FILES: + target_user.profile_picture = request.FILES["profile_picture"] + + # Handle password update if provided + if data.get("password"): + target_user.set_password(data["password"]) + + target_user.save() + + return JsonResponse( + { + "status": "success", + "message": "Partner user updated successfully.", + "user": _partner_user_to_dict(target_user, request), + }, + status=200, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerDeleteUserAPI(APIView): + """ + Partner Delete User API. + Body: token, username, user_id (required). + Returns: success message. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + # Check if user has partner role + partner_roles = ["partner", "partner_manager", "partner_staff"] + if user.role not in partner_roles: + return JsonResponse( + {"status": "error", "message": "You are not authorized to access this page."}, + status=403, + ) + + user_id = data.get("user_id") + if not user_id: + return JsonResponse( + {"status": "error", "message": "user_id is required."}, + status=400, + ) + + try: + target_user = User.objects.get(id=user_id) + except User.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "User not found."}, + status=404, + ) + + # Validate that the user has a partner-related role + all_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"] + if target_user.role not in all_partner_roles: + return JsonResponse( + { + "status": "error", + "message": "User is not a partner-related user. Only users with partner roles can be deleted.", + }, + status=400, + ) + + # Prevent deleting yourself + if target_user.id == user.id: + return JsonResponse( + {"status": "error", "message": "You cannot delete your own account."}, + status=400, + ) + + username = target_user.username + target_user.delete() + + return JsonResponse( + { + "status": "success", + "message": f"Partner user '{username}' deleted successfully.", + }, + status=200, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class LoginAPI(APIView): + """ + Admin/Staff Login API (accounts). + Body: username (or email), password (required). + Returns: token and user details for admin/manager/staff roles. + """ + + def post(self, request): + try: + # Parse JSON or form data + is_multipart = request.content_type and "multipart/form-data" in request.content_type + if is_multipart: + data = request.POST.dict() + else: + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse( + {"status": "error", "message": "Invalid JSON"}, + status=400, + ) + + username = data.get("username") or data.get("email") + password = data.get("password") + + if not username or not password: + return JsonResponse( + {"status": "error", "message": "username and password are required."}, + status=400, + ) + + user = authenticate(request, username=username, password=password) + if not user: + return JsonResponse( + {"status": "error", "message": "Invalid username or password."}, + status=401, + ) + + # Only allow admin/manager/staff to use this login + allowed_roles = ["admin", "manager", "staff"] + if user.role not in allowed_roles: + return JsonResponse( + {"status": "error", "message": "You are not authorized to access the admin portal."}, + status=403, + ) + + token, _ = Token.objects.get_or_create(user=user) + + return JsonResponse( + { + "status": "success", + "message": "Login successful", + "token": token.key, + "user": _user_to_dict(user, request), + }, + status=200, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class LogoutAPI(APIView): + """ + Logout API for token-based sessions. + Body: token, username (required). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + logout(request) + token.delete() + + return JsonResponse( + { + "status": "success", + "message": "Logout successful.", + }, + status=200, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class UserListAPI(APIView): + """ + List users (admin / manager / staff only). + Body: token, username (required); optional role filter. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + # Only allow admin/manager/staff to list users + allowed_roles = ["admin", "manager", "staff"] + if user.role not in allowed_roles: + return JsonResponse( + {"status": "error", "message": "You are not authorized to list users."}, + status=403, + ) + + qs = User.objects.all().order_by("-id") + role_filter = data.get("role") + if role_filter: + qs = qs.filter(role=role_filter) + + users = [_user_to_dict(u, request) for u in qs] + + return JsonResponse( + { + "status": "success", + "users": users, + "total_count": len(users), + }, + status=200, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class UserCreateAPI(APIView): + """ + Create a user (admin / manager / staff only). + Body: token, username, new_username, email, password, role ('admin'|'manager'|'staff'), phone_number (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + allowed_roles = ["admin", "manager", "staff"] + if user.role not in allowed_roles: + return JsonResponse( + {"status": "error", "message": "You are not authorized to create users."}, + status=403, + ) + + new_username = data.get("username") or data.get("new_username") + email = data.get("email") + password = data.get("password") + role = data.get("role") + + if not all([new_username, email, password, role]): + return JsonResponse( + { + "status": "error", + "message": "username, email, password, and role are required.", + }, + status=400, + ) + + valid_roles = ["admin", "manager", "staff"] + if role not in valid_roles: + return JsonResponse( + { + "status": "error", + "message": f"Invalid role. Must be one of: {', '.join(valid_roles)}", + }, + status=400, + ) + + if User.objects.filter(username=new_username).exists(): + return JsonResponse( + {"status": "error", "message": "Username already exists."}, + status=400, + ) + + if User.objects.filter(email=email).exists(): + return JsonResponse( + {"status": "error", "message": "Email already exists."}, + status=400, + ) + + new_user = User.objects.create_user( + username=new_username, + email=email, + password=password, + role=role, + phone_number=data.get("phone_number"), + ) + + return JsonResponse( + { + "status": "success", + "message": f"User created successfully with role: {role}.", + "user": _user_to_dict(new_user, request), + }, + status=201, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class UserUpdateAPI(APIView): + """ + Update a user (admin / manager / staff only). + Body: token, username, user_id (required); email, phone_number, role (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + allowed_roles = ["admin", "manager", "staff"] + if user.role not in allowed_roles: + return JsonResponse( + {"status": "error", "message": "You are not authorized to update users."}, + status=403, + ) + + user_id = data.get("user_id") + if not user_id: + return JsonResponse( + {"status": "error", "message": "user_id is required."}, + status=400, + ) + + try: + target_user = User.objects.get(id=user_id) + except User.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "User not found."}, + status=404, + ) + + if data.get("email") is not None: + new_email = data["email"] + if User.objects.filter(email=new_email).exclude(id=user_id).exists(): + return JsonResponse( + {"status": "error", "message": "Email already exists for another user."}, + status=400, + ) + target_user.email = new_email + + if data.get("phone_number") is not None: + target_user.phone_number = data["phone_number"] or None + + if data.get("role") is not None: + new_role = data["role"] + valid_roles = ["admin", "manager", "staff"] + if new_role not in valid_roles: + return JsonResponse( + { + "status": "error", + "message": f"Invalid role. Must be one of: {', '.join(valid_roles)}", + }, + status=400, + ) + target_user.role = new_role + + target_user.save() + + return JsonResponse( + { + "status": "success", + "message": "User updated successfully.", + "user": _user_to_dict(target_user, request), + }, + status=200, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class UserDeleteAPI(APIView): + """ + Delete a user (admin / manager / staff only). + Body: token, username, user_id (required). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + allowed_roles = ["admin", "manager", "staff"] + if user.role not in allowed_roles: + return JsonResponse( + {"status": "error", "message": "You are not authorized to delete users."}, + status=403, + ) + + user_id = data.get("user_id") + if not user_id: + return JsonResponse( + {"status": "error", "message": "user_id is required."}, + status=400, + ) + + try: + target_user = User.objects.get(id=user_id) + except User.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "User not found."}, + status=404, + ) + + if target_user.id == user.id: + return JsonResponse( + {"status": "error", "message": "You cannot delete your own account."}, + status=400, + ) + + username = target_user.username + target_user.delete() + + return JsonResponse( + { + "status": "success", + "message": f"User '{username}' deleted successfully.", + }, + status=200, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + diff --git a/accounts/forms.py b/accounts/forms.py index 05cb27b..a3bd0f5 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -88,3 +88,110 @@ class LoginForm(AuthenticationForm): "placeholder": "Enter password" }) ) + + +class PartnerUserForm(forms.ModelForm): + full_name = forms.CharField( + max_length=150, + required=True, + label="Full Name" + ) + password = forms.CharField( + widget=forms.PasswordInput, + label="Password", + required=True, + help_text="Required for new users. Leave blank if you don't want to change the password when editing." + ) + confirm_password = forms.CharField( + widget=forms.PasswordInput, + label="Confirm Password", + required=True + ) + + phone_number = forms.CharField( + max_length=15, + required=False, + label="Phone Number" + ) + + ROLE_CHOICES = [ + ('partner', 'Partner'), + ('partner_manager', 'Partner Manager'), + ('partner_staff', 'Partner Staff'), + ('partner_customer', 'Partner Customer'), + ] + + role = forms.ChoiceField( + choices=ROLE_CHOICES, + required=True, + label="Role" + ) + + class Meta: + model = User + fields = ["username", "email", "phone_number", "role"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + field.widget.attrs.update({"class": "form-control"}) + + # Make password fields optional for updates, required for new users + if self.instance and self.instance.pk: + self.fields['password'].required = False + self.fields['confirm_password'].required = False + # Pre-populate full_name from first_name and last_name + if self.instance.first_name or self.instance.last_name: + self.fields['full_name'].initial = f"{self.instance.first_name} {self.instance.last_name}".strip() + else: + # For new users, password is required + self.fields['password'].required = True + self.fields['confirm_password'].required = True + + def clean(self): + cleaned_data = super().clean() + password = cleaned_data.get("password") + confirm_password = cleaned_data.get("confirm_password") + + # For new users, password is required + if not self.instance or not self.instance.pk: + if not password: + self.add_error("password", "Password is required for new users.") + if not confirm_password: + self.add_error("confirm_password", "Please confirm your password.") + + # Validate password match if password is provided + if password or confirm_password: + if password != confirm_password: + self.add_error("confirm_password", "Passwords do not match!") + + return cleaned_data + + def save(self, commit=True): + user = super().save(commit=False) + + # Set password - required for new users, optional for updates + password = self.cleaned_data.get('password') + if password: + user.set_password(password) + elif not user.pk: + # New user must have a password + raise ValueError("Password is required for new users.") + + # Save phone_number and role to the User model + user.phone_number = self.cleaned_data.get("phone_number") + user.role = self.cleaned_data.get("role") + + # Handle full_name - split into first_name and last_name + full_name = self.cleaned_data.get("full_name", "").strip() + if full_name: + parts = full_name.split(None, 1) + user.first_name = parts[0] + if len(parts) > 1: + user.last_name = parts[1] + else: + user.last_name = "" + + if commit: + user.save() + return user diff --git a/accounts/migrations/0008_alter_user_role.py b/accounts/migrations/0008_alter_user_role.py new file mode 100644 index 0000000..ba51025 --- /dev/null +++ b/accounts/migrations/0008_alter_user_role.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.27 on 2026-03-13 16:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0007_alter_user_profile_picture'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='role', + field=models.CharField(choices=[('admin', 'Admin'), ('manager', 'Manager'), ('staff', 'Staff'), ('customer', 'Customer'), ('partner', 'Partner'), ('partner_manager', 'Partner Manager'), ('partner_staff', 'Partner Staff'), ('partner_customer', 'Partner Customer')], default='Staff', max_length=20), + ), + ] diff --git a/accounts/migrations/0009_user_partner.py b/accounts/migrations/0009_user_partner.py new file mode 100644 index 0000000..a873040 --- /dev/null +++ b/accounts/migrations/0009_user_partner.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.27 on 2026-03-14 07:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('partner', '0001_initial'), + ('accounts', '0008_alter_user_role'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='partner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='partner.partner'), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index f40fb2f..ec45ae2 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -2,18 +2,24 @@ from django.contrib.auth.models import AbstractUser from django.db import models from accounts.manager import UserManager - +from partner.models import Partner ROLE_CHOICES = ( ('admin', 'Admin'), ('manager', 'Manager'), ('staff', 'Staff'), + ('customer', 'Customer'), + ('partner', 'Partner'), + ('partner_manager', 'Partner Manager'), + ('partner_staff', 'Partner Staff'), + ('partner_customer', 'Partner Customer'), ) - class User(AbstractUser): phone_number = models.CharField(max_length=15, blank=True, null=True) role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='Staff') + partner = models.ForeignKey(Partner, on_delete=models.CASCADE, blank=True, null=True) + is_staff = models.BooleanField(default=False) is_customer = models.BooleanField(default=False) is_user = models.BooleanField(default=False) diff --git a/accounts/urls.py b/accounts/urls.py index 28dd2ed..7a5cb35 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -1,14 +1,37 @@ from django.urls import path -from . import views +from . import views, api -app_name = 'accounts' +app_name = "accounts" urlpatterns = [ - path('login/', views.login_view, name='login'), - path('logout/', views.logout_view, name='logout'), - path('dashboard/', views.dashboard, name='dashboard'), - path('users/', views.UserListView.as_view(), name='user_list'), - path('users/add/', views.UserCreateView.as_view(), name='user_add'), - path('users//edit/', views.UserUpdateView.as_view(), name='user_edit'), - path('users//delete/', views.UserDeleteView.as_view(), name='user_delete'), + path("login/", views.login_view, name="login"), + path("logout/", views.logout_view, name="logout"), + path("dashboard/", views.dashboard, name="dashboard"), + path("users/", views.UserListView.as_view(), name="user_list"), + path("users/add/", views.UserCreateView.as_view(), name="user_add"), + path("users//edit/", views.UserUpdateView.as_view(), name="user_edit"), + path("users//delete/", views.UserDeleteView.as_view(), name="user_delete"), ] + + +# Core account APIs (admin/staff) +urlpatterns += [ + path("api/login/", api.LoginAPI.as_view(), name="api_login"), + path("api/logout/", api.LogoutAPI.as_view(), name="api_logout"), + path("api/users/list/", api.UserListAPI.as_view(), name="api_user_list"), + path("api/users/create/", api.UserCreateAPI.as_view(), name="api_user_create"), + path("api/users/update/", api.UserUpdateAPI.as_view(), name="api_user_update"), + path("api/users/delete/", api.UserDeleteAPI.as_view(), name="api_user_delete"), +] + + +# Partner APIs +urlpatterns += [ + path("api/partner/login/", api.PartnerLoginAPI.as_view(), name="partner_api_login"), + path("api/partner/logout/", api.PartnerLogoutAPI.as_view(), name="partner_api_logout"), + path("api/partner/dashboard/", api.PartnerDashboardAPI.as_view(), name="partner_api_dashboard"), + path("api/partner/users/list/", api.PartnerListUsersAPI.as_view(), name="partner_api_user_list"), + path("api/partner/users/create/", api.PartnerCreateUserAPI.as_view(), name="partner_api_user_create"), + path("api/partner/users/update/", api.PartnerUpdateUserAPI.as_view(), name="partner_api_user_update"), + path("api/partner/users/delete/", api.PartnerDeleteUserAPI.as_view(), name="partner_api_user_delete"), +] \ No newline at end of file diff --git a/accounts/views.py b/accounts/views.py index 5cf344e..cb1b208 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,16 +1,16 @@ -from django.shortcuts import render +from django.shortcuts import render, redirect from django.views import generic from django.urls import reverse_lazy from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import PermissionDenied +from django.contrib import messages +from django.contrib.auth import authenticate, login, logout + from .models import User -from .forms import LoginForm -from .forms import UserForm +from .forms import LoginForm, UserForm, PartnerUserForm from events.models import Event from master_data.models import EventType - -from django.contrib.auth import authenticate, login, logout -from django.shortcuts import redirect -from django.contrib import messages +from eventify_logger.services import log def dashboard(request): @@ -62,16 +62,150 @@ def login_view(request): user = form.get_user() login(request, user) if user.role == 'admin' or user.role == 'manager' or user.role == 'staff': + log("info", "Admin/Manager/Staff login", request=request, user=user) return redirect("accounts:dashboard") else: + log("warning", "Login attempt - user not authorized", request=request, user=user) messages.error(request, "You are not authorized to access this page.") else: + log("warning", "Invalid login attempt", request=request) messages.error(request, "Invalid username or password") return render(request, "accounts/login.html", {"form": form}) def logout_view(request): + if request.user.is_authenticated: + log("info", "User logout", request=request, user=request.user) logout(request) messages.success(request, "You have been logged out successfully.") - return redirect("accounts:login") \ No newline at end of file + return redirect("accounts:login") + + +# Partner Views Mixin +class PartnerRequiredMixin(LoginRequiredMixin): + """Mixin to ensure user has partner role (partner, partner_manager, partner_staff)""" + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return self.handle_no_permission() + partner_roles = ['partner', 'partner_manager', 'partner_staff'] + if request.user.role not in partner_roles: + raise PermissionDenied("You are not authorized to access this page.") + return super().dispatch(request, *args, **kwargs) + + +# Partner Login/Logout/Dashboard +def partner_login_view(request): + if request.user.is_authenticated: + partner_roles = ['partner', 'partner_manager', 'partner_staff'] + if request.user.role in partner_roles: + return redirect("accounts:partner_dashboard") + else: + messages.error(request, "You are not authorized to access partner portal.") + return redirect("accounts:login") + + form = LoginForm(request, data=request.POST or None) + + if request.method == "POST": + if form.is_valid(): + user = form.get_user() + partner_roles = ['partner', 'partner_manager', 'partner_staff'] + if user.role in partner_roles: + log("info", "Partner portal login", request=request, user=user) + login(request, user) + return redirect("accounts:partner_dashboard") + else: + log("warning", "Partner login - user not authorized", request=request, user=user) + messages.error(request, "You are not authorized to access partner portal.") + else: + log("warning", "Partner portal - invalid login attempt", request=request) + messages.error(request, "Invalid username or password") + + return render(request, "partner/login.html", {"form": form}) + + +def partner_logout_view(request): + if request.user.is_authenticated: + log("info", "Partner portal logout", request=request, user=request.user) + logout(request) + messages.success(request, "You have been logged out successfully.") + return redirect("accounts:partner_login") + + +def partner_dashboard(request): + """Partner dashboard view""" + partner_roles = ['partner', 'partner_manager', 'partner_staff'] + if not request.user.is_authenticated or request.user.role not in partner_roles: + messages.error(request, "You are not authorized to access this page.") + return redirect("accounts:partner_login") + + # Get statistics for partner users (including partner_customer) + all_partner_roles = ['partner', 'partner_manager', 'partner_staff', 'partner_customer'] + partner_users = User.objects.filter(role__in=all_partner_roles) + total_partner_users = partner_users.count() + + # You can add more partner-specific statistics here + # For example, events created by partner, bookings, etc. + + return render(request, 'partner/dashboard.html', { + 'total_partner_users': total_partner_users, + }) + + +# Partner User Management Views +class PartnerUserListView(PartnerRequiredMixin, generic.ListView): + model = User + template_name = 'partner/user_list.html' + context_object_name = 'users' + paginate_by = 20 + + def get_queryset(self): + """Filter users to show only partner-related roles""" + partner_roles = ['partner', 'partner_manager', 'partner_staff', 'partner_customer'] + return User.objects.filter(role__in=partner_roles).order_by('-id') + + +class PartnerUserCreateView(PartnerRequiredMixin, generic.CreateView): + model = User + form_class = PartnerUserForm + template_name = 'partner/user_form.html' + success_url = reverse_lazy('accounts:partner_user_list') + + def form_valid(self, form): + messages.success(self.request, "Partner user created successfully.") + return super().form_valid(form) + + +class PartnerUserUpdateView(PartnerRequiredMixin, generic.UpdateView): + model = User + form_class = PartnerUserForm + template_name = 'partner/user_form.html' + success_url = reverse_lazy('accounts:partner_user_list') + + def get_queryset(self): + """Only allow editing users with partner-related roles""" + partner_roles = ['partner', 'partner_manager', 'partner_staff', 'partner_customer'] + return User.objects.filter(role__in=partner_roles) + + def form_valid(self, form): + messages.success(self.request, "Partner user updated successfully.") + return super().form_valid(form) + + +class PartnerUserDeleteView(PartnerRequiredMixin, generic.DeleteView): + model = User + template_name = 'partner/user_confirm_delete.html' + success_url = reverse_lazy('accounts:partner_user_list') + + def get_queryset(self): + """Only allow deleting users with partner-related roles""" + partner_roles = ['partner', 'partner_manager', 'partner_staff', 'partner_customer'] + return User.objects.filter(role__in=partner_roles) + + def delete(self, request, *args, **kwargs): + # Prevent users from deleting themselves + if self.get_object().id == request.user.id: + messages.error(request, "You cannot delete your own account.") + return redirect(self.success_url) + messages.success(request, "Partner user deleted successfully.") + return super().delete(request, *args, **kwargs) \ No newline at end of file diff --git a/banking_operations/__init__.py b/banking_operations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/banking_operations/admin.py b/banking_operations/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/banking_operations/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/banking_operations/api.py b/banking_operations/api.py new file mode 100644 index 0000000..67d70c6 --- /dev/null +++ b/banking_operations/api.py @@ -0,0 +1,432 @@ +import uuid +from django.http import JsonResponse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.forms.models import model_to_dict + +from rest_framework.views import APIView + +from banking_operations.models import PaymentGateway, PaymentGatewayCredentials +from mobile_api.utils import validate_token_and_get_user + + +def _payment_gateway_to_dict(gateway, request=None): + """Serialize PaymentGateway for JSON.""" + data = model_to_dict( + gateway, + fields=[ + "id", + "payment_gateway_id", + "payment_gateway_name", + "payment_gateway_description", + "payment_gateway_url", + "payment_gateway_api_key", + "payment_gateway_api_secret", + "payment_gateway_api_url", + "payment_gateway_api_version", + "payment_gateway_api_method", + "is_active", + "created_date", + "updated_date", + "gateway_priority", + ], + ) + # Add logo URL if exists + if gateway.payment_gateway_logo: + if request: + data["payment_gateway_logo"] = request.build_absolute_uri(gateway.payment_gateway_logo.url) + else: + data["payment_gateway_logo"] = gateway.payment_gateway_logo.url + else: + data["payment_gateway_logo"] = None + return data + + +@method_decorator(csrf_exempt, name="dispatch") +class PaymentGatewayCreateAPI(APIView): + """ + Create a new PaymentGateway. + Body: token, username, payment_gateway_name, payment_gateway_description, + payment_gateway_api_key, payment_gateway_api_secret, payment_gateway_api_version, + payment_gateway_api_method (required); + payment_gateway_logo (file upload), payment_gateway_url, payment_gateway_api_url, + is_active, gateway_priority (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + payment_gateway_name = data.get("payment_gateway_name") + payment_gateway_description = data.get("payment_gateway_description") + payment_gateway_api_key = data.get("payment_gateway_api_key") + payment_gateway_api_secret = data.get("payment_gateway_api_secret") + payment_gateway_api_version = data.get("payment_gateway_api_version") + payment_gateway_api_method = data.get("payment_gateway_api_method") + + if not all([ + payment_gateway_name, + payment_gateway_description, + payment_gateway_api_key, + payment_gateway_api_secret, + payment_gateway_api_version, + payment_gateway_api_method, + ]): + return JsonResponse( + { + "status": "error", + "message": "payment_gateway_name, payment_gateway_description, payment_gateway_api_key, " + "payment_gateway_api_secret, payment_gateway_api_version, and " + "payment_gateway_api_method are required.", + }, + status=400, + ) + + # Generate payment_gateway_id if not provided + payment_gateway_id = data.get("payment_gateway_id") + if not payment_gateway_id: + payment_gateway_id = str(uuid.uuid4().hex[:10]).upper() + + gateway = PaymentGateway.objects.create( + payment_gateway_id=payment_gateway_id, + payment_gateway_name=payment_gateway_name, + payment_gateway_description=payment_gateway_description, + payment_gateway_url=data.get("payment_gateway_url"), + payment_gateway_api_key=payment_gateway_api_key, + payment_gateway_api_secret=payment_gateway_api_secret, + payment_gateway_api_url=data.get("payment_gateway_api_url"), + payment_gateway_api_version=payment_gateway_api_version, + payment_gateway_api_method=payment_gateway_api_method, + is_active=data.get("is_active", True), + gateway_priority=data.get("gateway_priority", 0), + ) + + # Handle logo upload if provided + if "payment_gateway_logo" in request.FILES: + gateway.payment_gateway_logo = request.FILES["payment_gateway_logo"] + gateway.save() + + return JsonResponse( + {"status": "success", "payment_gateway": _payment_gateway_to_dict(gateway, request)}, + status=201, + ) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PaymentGatewayListAPI(APIView): + """ + List PaymentGateways, optionally filtered by is_active. + Body: token, username, is_active (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + qs = PaymentGateway.objects.all().order_by("-gateway_priority", "-created_date") + + is_active = data.get("is_active") + if is_active is not None: + qs = qs.filter(is_active=bool(is_active)) + + gateways = [_payment_gateway_to_dict(g, request) for g in qs] + return JsonResponse({"status": "success", "payment_gateways": gateways}, status=200) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PaymentGatewayUpdateAPI(APIView): + """ + Update an existing PaymentGateway. + Body: token, username, payment_gateway_id (required); + payment_gateway_name, payment_gateway_description, payment_gateway_url, + payment_gateway_api_key, payment_gateway_api_secret, payment_gateway_api_url, + payment_gateway_api_version, payment_gateway_api_method, is_active, + gateway_priority (optional); + payment_gateway_logo (file upload, optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + payment_gateway_id = data.get("payment_gateway_id") + if not payment_gateway_id: + return JsonResponse( + {"status": "error", "message": "payment_gateway_id is required."}, + status=400, + ) + + try: + gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway_id) + except PaymentGateway.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "PaymentGateway not found."}, + status=404, + ) + + # Update fields if provided + if data.get("payment_gateway_name") is not None: + gateway.payment_gateway_name = data["payment_gateway_name"] + if data.get("payment_gateway_description") is not None: + gateway.payment_gateway_description = data["payment_gateway_description"] + if "payment_gateway_url" in data: + gateway.payment_gateway_url = data["payment_gateway_url"] or None + if data.get("payment_gateway_api_key") is not None: + gateway.payment_gateway_api_key = data["payment_gateway_api_key"] + if data.get("payment_gateway_api_secret") is not None: + gateway.payment_gateway_api_secret = data["payment_gateway_api_secret"] + if "payment_gateway_api_url" in data: + gateway.payment_gateway_api_url = data["payment_gateway_api_url"] or None + if data.get("payment_gateway_api_version") is not None: + gateway.payment_gateway_api_version = data["payment_gateway_api_version"] + if data.get("payment_gateway_api_method") is not None: + gateway.payment_gateway_api_method = data["payment_gateway_api_method"] + if data.get("is_active") is not None: + gateway.is_active = bool(data["is_active"]) + if data.get("gateway_priority") is not None: + try: + gateway.gateway_priority = int(data["gateway_priority"]) + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "gateway_priority must be an integer."}, + status=400, + ) + + # Handle logo upload if provided + if "payment_gateway_logo" in request.FILES: + gateway.payment_gateway_logo = request.FILES["payment_gateway_logo"] + + gateway.save() + return JsonResponse( + {"status": "success", "payment_gateway": _payment_gateway_to_dict(gateway, request)}, + status=200, + ) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PaymentGatewayDeleteAPI(APIView): + """ + Delete an existing PaymentGateway. + Body: token, username, payment_gateway_id. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + payment_gateway_id = data.get("payment_gateway_id") + if not payment_gateway_id: + return JsonResponse( + {"status": "error", "message": "payment_gateway_id is required."}, + status=400, + ) + + try: + gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway_id) + except PaymentGateway.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "PaymentGateway not found."}, + status=404, + ) + + gateway_name = gateway.payment_gateway_name + gateway.delete() + return JsonResponse( + {"status": "success", "message": f"PaymentGateway '{gateway_name}' deleted successfully."}, + status=200, + ) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +def _payment_gateway_credentials_to_dict(credentials, request=None): + """Serialize PaymentGatewayCredentials for JSON.""" + data = model_to_dict( + credentials, + fields=[ + "id", + "payment_gateway_credentials_value", + "created_date", + "updated_date", + ], + ) + data["payment_gateway_id"] = credentials.payment_gateway_id + data["payment_gateway_name"] = credentials.payment_gateway.payment_gateway_name + data["payment_gateway_payment_gateway_id"] = credentials.payment_gateway.payment_gateway_id + return data + + +@method_decorator(csrf_exempt, name="dispatch") +class PaymentGatewayCredentialsCreateAPI(APIView): + """ + Create a new PaymentGatewayCredentials. + Body: token, username, payment_gateway_id (or payment_gateway_payment_gateway_id), + payment_gateway_credentials_value (required). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + payment_gateway_id = data.get("payment_gateway_id") or data.get("payment_gateway_payment_gateway_id") + payment_gateway_credentials_value = data.get("payment_gateway_credentials_value") + + if not payment_gateway_id or not payment_gateway_credentials_value: + return JsonResponse( + { + "status": "error", + "message": "payment_gateway_id (or payment_gateway_payment_gateway_id) and payment_gateway_credentials_value are required.", + }, + status=400, + ) + + try: + gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway_id) + except PaymentGateway.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "PaymentGateway not found."}, + status=404, + ) + + credentials = PaymentGatewayCredentials.objects.create( + payment_gateway=gateway, + payment_gateway_credentials_value=payment_gateway_credentials_value, + ) + + return JsonResponse( + {"status": "success", "credentials": _payment_gateway_credentials_to_dict(credentials, request)}, + status=201, + ) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PaymentGatewayCredentialsListAPI(APIView): + """ + List PaymentGatewayCredentials, optionally filtered by payment_gateway_id. + Body: token, username, payment_gateway_id (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + qs = PaymentGatewayCredentials.objects.select_related("payment_gateway").all().order_by("-created_date") + + payment_gateway_id = data.get("payment_gateway_id") or data.get("payment_gateway_payment_gateway_id") + if payment_gateway_id: + try: + gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway_id) + qs = qs.filter(payment_gateway=gateway) + except PaymentGateway.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "PaymentGateway not found."}, + status=404, + ) + + credentials_list = [_payment_gateway_credentials_to_dict(c, request) for c in qs] + return JsonResponse({"status": "success", "credentials": credentials_list}, status=200) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PaymentGatewayCredentialsUpdateAPI(APIView): + """ + Update an existing PaymentGatewayCredentials. + Body: token, username, credentials_id (required); + payment_gateway_credentials_value (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + credentials_id = data.get("credentials_id") + if not credentials_id: + return JsonResponse( + {"status": "error", "message": "credentials_id is required."}, + status=400, + ) + + try: + credentials = PaymentGatewayCredentials.objects.select_related("payment_gateway").get(id=credentials_id) + except PaymentGatewayCredentials.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "PaymentGatewayCredentials not found."}, + status=404, + ) + + # Update credentials value if provided + if data.get("payment_gateway_credentials_value") is not None: + credentials.payment_gateway_credentials_value = data["payment_gateway_credentials_value"] + + credentials.save() + return JsonResponse( + {"status": "success", "credentials": _payment_gateway_credentials_to_dict(credentials, request)}, + status=200, + ) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PaymentGatewayCredentialsDeleteAPI(APIView): + """ + Delete an existing PaymentGatewayCredentials. + Body: token, username, credentials_id. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + credentials_id = data.get("credentials_id") + if not credentials_id: + return JsonResponse( + {"status": "error", "message": "credentials_id is required."}, + status=400, + ) + + try: + credentials = PaymentGatewayCredentials.objects.select_related("payment_gateway").get(id=credentials_id) + except PaymentGatewayCredentials.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "PaymentGatewayCredentials not found."}, + status=404, + ) + + gateway_name = credentials.payment_gateway.payment_gateway_name + credentials.delete() + return JsonResponse( + { + "status": "success", + "message": f"PaymentGatewayCredentials for '{gateway_name}' deleted successfully.", + }, + status=200, + ) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) diff --git a/banking_operations/apps.py b/banking_operations/apps.py new file mode 100644 index 0000000..2dc53cb --- /dev/null +++ b/banking_operations/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BankingOperationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'banking_operations' diff --git a/banking_operations/migrations/0001_initial.py b/banking_operations/migrations/0001_initial.py new file mode 100644 index 0000000..9433f60 --- /dev/null +++ b/banking_operations/migrations/0001_initial.py @@ -0,0 +1,69 @@ +# Generated by Django 4.2.27 on 2026-03-13 16:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PaymentGateway', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('payment_gateway_id', models.CharField(max_length=250)), + ('payment_gateway_name', models.CharField(max_length=250)), + ('payment_gateway_description', models.TextField()), + ('payment_gateway_logo', models.ImageField(blank=True, null=True, upload_to='payment_gateways/')), + ('payment_gateway_url', models.URLField(blank=True, null=True)), + ('payment_gateway_api_key', models.CharField(max_length=250)), + ('payment_gateway_api_secret', models.CharField(max_length=250)), + ('payment_gateway_api_url', models.URLField(blank=True, null=True)), + ('payment_gateway_api_version', models.CharField(max_length=250)), + ('payment_gateway_api_method', models.CharField(max_length=250)), + ('is_active', models.BooleanField(default=True)), + ('created_date', models.DateField(auto_now_add=True)), + ('updated_date', models.DateField(auto_now=True)), + ('gateway_priority', models.IntegerField(default=0)), + ], + ), + migrations.CreateModel( + name='PaymentTransaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('payment_transaction_id', models.CharField(max_length=250)), + ('payment_type', models.CharField(choices=[('credit', 'Credit'), ('debit', 'Debit'), ('transfer', 'Transfer'), ('other', 'Other')], max_length=250)), + ('payment_sub_type', models.CharField(choices=[('online', 'Online'), ('offline', 'Offline'), ('other', 'Other')], max_length=250)), + ('payment_transaction_amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('payment_transaction_currency', models.CharField(max_length=10)), + ('payment_transaction_status', models.CharField(choices=[('pending', 'Pending'), ('completed', 'Completed'), ('failed', 'Failed'), ('refunded', 'Refunded'), ('cancelled', 'Cancelled')], max_length=250)), + ('payment_transaction_date', models.DateField(auto_now_add=True)), + ('payment_transaction_time', models.TimeField(auto_now_add=True)), + ('payment_transaction_notes', models.TextField(blank=True, null=True)), + ('payment_transaction_raw_data', models.JSONField(blank=True, null=True)), + ('payment_transaction_response', models.JSONField(blank=True, null=True)), + ('payment_transaction_error', models.JSONField(blank=True, null=True)), + ('last_updated_date', models.DateField(blank=True, null=True)), + ('last_updated_time', models.TimeField(blank=True, null=True)), + ('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('payment_gateway', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='banking_operations.paymentgateway')), + ], + ), + migrations.CreateModel( + name='PaymentGatewayCredentials', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('payment_gateway_credentials_value', models.TextField()), + ('created_date', models.DateField(auto_now_add=True)), + ('updated_date', models.DateField(auto_now=True)), + ('payment_gateway', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='banking_operations.paymentgateway')), + ], + ), + ] diff --git a/banking_operations/migrations/__init__.py b/banking_operations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/banking_operations/models.py b/banking_operations/models.py new file mode 100644 index 0000000..d562da8 --- /dev/null +++ b/banking_operations/models.py @@ -0,0 +1,85 @@ +from django.db import models +from django.contrib.auth import get_user_model +import uuid + +User = get_user_model() + +# Create your models here. +class PaymentGateway(models.Model): + payment_gateway_id = models.CharField(max_length=250) + payment_gateway_name = models.CharField(max_length=250) + payment_gateway_description = models.TextField() + payment_gateway_logo = models.ImageField(upload_to='payment_gateways/', blank=True, null=True) + payment_gateway_url = models.URLField(blank=True, null=True) + payment_gateway_api_key = models.CharField(max_length=250) + payment_gateway_api_secret = models.CharField(max_length=250) + payment_gateway_api_url = models.URLField(blank=True, null=True) + payment_gateway_api_version = models.CharField(max_length=250) + payment_gateway_api_method = models.CharField(max_length=250) + + is_active = models.BooleanField(default=True) + + created_date = models.DateField(auto_now_add=True) + updated_date = models.DateField(auto_now=True) + + gateway_priority = models.IntegerField(default=0) + + def __str__(self): + return self.payment_gateway_name + + def __save__(self): + if not self.payment_gateway_id: + self.payment_gateway_id = str(uuid.uuid4().hex[:10]).upper() + super().save(*args, **kwargs) + +class PaymentGatewayCredentials(models.Model): + payment_gateway = models.ForeignKey(PaymentGateway, on_delete=models.CASCADE) + payment_gateway_credentials_value = models.TextField() + created_date = models.DateField(auto_now_add=True) + updated_date = models.DateField(auto_now=True) + + def __str__(self): + return self.payment_gateway.payment_gateway_name + " - " + self.payment_gateway_credentials_value + + +class PaymentTransaction(models.Model): + payment_transaction_id = models.CharField(max_length=250) + payment_type = models.CharField(max_length=250, choices=[ + ('credit', 'Credit'), + ('debit', 'Debit'), + ('transfer', 'Transfer'), + ('other', 'Other'), + ]) + payment_sub_type = models.CharField(max_length=250, choices=[ + ('online', 'Online'), + ('offline', 'Offline'), + ('other', 'Other'), + ]) + payment_gateway = models.ForeignKey(PaymentGateway, on_delete=models.CASCADE) + payment_transaction_amount = models.DecimalField(max_digits=10, decimal_places=2) + payment_transaction_currency = models.CharField(max_length=10) + payment_transaction_status = models.CharField(max_length=250, choices=[ + ('pending', 'Pending'), + ('completed', 'Completed'), + ('failed', 'Failed'), + ('refunded', 'Refunded'), + ('cancelled', 'Cancelled'), + ]) + payment_transaction_date = models.DateField(auto_now_add=True) + payment_transaction_time = models.TimeField(auto_now_add=True) + payment_transaction_notes = models.TextField(blank=True, null=True) + payment_transaction_raw_data = models.JSONField(blank=True, null=True) + payment_transaction_response = models.JSONField(blank=True, null=True) + payment_transaction_error = models.JSONField(blank=True, null=True) + + last_updated_date = models.DateField(blank=True, null=True) + last_updated_time = models.TimeField(blank=True, null=True) + last_updated_by = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True) + + def __str__(self): + return self.payment_gateway.payment_gateway_name + " - " + self.payment_transaction_id + + def __save__(self): + if not self.payment_transaction_id: + self.payment_transaction_id = str(self.payment_gateway.payment_gateway_name[:3].upper()) + str(uuid.uuid4().hex[:10]).upper() + super().save(*args, **kwargs) \ No newline at end of file diff --git a/banking_operations/services.py b/banking_operations/services.py new file mode 100644 index 0000000..0a6f3c5 --- /dev/null +++ b/banking_operations/services.py @@ -0,0 +1,170 @@ +""" +Banking/payment services. transaction_initiate is called by checkout (and others) +to start a payment flow. Replace the stub with real gateway integration (e.g. Razorpay). +""" +from decimal import Decimal +from django.contrib.auth import get_user_model +from banking_operations.models import PaymentTransaction, PaymentGateway + +User = get_user_model() + + +def transaction_initiate( + request, + user, + amount, + currency="INR", + reference_type="checkout", + reference_id=None, + bookings=None, + extra_data=None, +): + """ + Initiate a payment transaction (e.g. create Razorpay order and return payment URL). + + Args: + request: Django request (for building URLs, gateway config, etc.). + user: User instance (customer). + amount: Total amount in payment currency (Decimal or float). + currency: Currency code, e.g. "INR". + reference_type: Application reference type, e.g. "checkout". + reference_id: Application reference id (e.g. booking_ids or order id). + bookings: Optional list of Booking instances or IDs linked to this transaction. + extra_data: Optional dict for gateway-specific data. + + Returns: + dict: On success: {"success": True, "transaction_id": "...", "payment_url": "...", "message": "..."} + On failure: {"success": False, "message": "..."} + """ + # Stub: replace with real gateway call when banking_operations payment flow is implemented. + return { + "success": True, + "message": "Transaction initiation stub", + "transaction_id": None, + "payment_url": None, + } + + +def create_payment_transaction( + payment_type, + payment_sub_type, + payment_gateway, + transaction_amount, + currency="INR", + notes=None, + raw_data=None, + user=None, +): + """ + Create a PaymentTransaction with pending status. + + Args: + payment_type: Payment type - 'credit', 'debit', 'transfer', or 'other' + payment_sub_type: Payment sub type - 'online', 'offline', or 'other' + payment_gateway: PaymentGateway instance or payment_gateway_id (str) + transaction_amount: Transaction amount (Decimal, float, or string) + currency: Currency code, e.g. "INR" (default: "INR") + notes: Optional transaction notes (str) + raw_data: Optional raw data dict to store in payment_transaction_raw_data (dict) + user: Optional User instance for last_updated_by + + Returns: + tuple: (success: bool, transaction: PaymentTransaction or None, error_message: str or None) + """ + try: + # Validate payment_type + valid_payment_types = ['credit', 'debit', 'transfer', 'other'] + if payment_type not in valid_payment_types: + return False, None, f"Invalid payment_type. Must be one of: {', '.join(valid_payment_types)}" + + # Validate payment_sub_type + valid_payment_sub_types = ['online', 'offline', 'other'] + if payment_sub_type not in valid_payment_sub_types: + return False, None, f"Invalid payment_sub_type. Must be one of: {', '.join(valid_payment_sub_types)}" + + # Get PaymentGateway instance + if isinstance(payment_gateway, PaymentGateway): + gateway = payment_gateway + elif isinstance(payment_gateway, str): + try: + gateway = PaymentGateway.objects.get(payment_gateway_id=payment_gateway) + except PaymentGateway.DoesNotExist: + return False, None, f"PaymentGateway with id '{payment_gateway}' not found." + else: + return False, None, "payment_gateway must be a PaymentGateway instance or payment_gateway_id string." + + # Validate transaction_amount + try: + amount = Decimal(str(transaction_amount)) + if amount <= 0: + return False, None, "transaction_amount must be greater than zero." + except (ValueError, TypeError): + return False, None, "transaction_amount must be a valid number." + + # Validate currency + if not currency or len(currency) > 10: + return False, None, "currency must be a valid currency code (max 10 characters)." + + # Create PaymentTransaction + transaction = PaymentTransaction.objects.create( + payment_type=payment_type, + payment_sub_type=payment_sub_type, + payment_gateway=gateway, + payment_transaction_amount=amount, + payment_transaction_currency=currency, + payment_transaction_status='pending', # Initial state as requested + payment_transaction_notes=notes, + payment_transaction_raw_data=raw_data, + last_updated_by=user if isinstance(user, User) else None, + ) + + return True, transaction.payment_transaction_id, None + + except Exception as e: + return False, None, str(e) + +def update_payment_transaction( + payment_transaction_id, + payment_transaction_status, + payment_transaction_notes=None, + payment_transaction_raw_data=None, + payment_transaction_response=None, + payment_transaction_error=None, + user=None, +): + """ + Update a PaymentTransaction with the given status and notes. + Args: + payment_transaction_id: PaymentTransaction id (str) + payment_transaction_status: PaymentTransaction status - 'pending', 'completed', 'failed', 'refunded', 'cancelled' + payment_transaction_notes: Optional transaction notes (str) + payment_transaction_raw_data: Optional raw data dict to store in payment_transaction_raw_data (dict) + payment_transaction_response: Optional response dict to store in payment_transaction_response (dict) + payment_transaction_error: Optional error dict to store in payment_transaction_error (dict) + user: Optional User instance for last_updated_by + """ + try: + # Get PaymentTransaction instance + if isinstance(payment_transaction_id, PaymentTransaction): + transaction = payment_transaction_id + elif isinstance(payment_transaction_id, str): + try: + transaction = PaymentTransaction.objects.get(payment_transaction_id=payment_transaction_id) + except PaymentTransaction.DoesNotExist: + return False, None, f"PaymentTransaction with id '{payment_transaction_id}' not found." + else: + return False, None, "payment_transaction_id must be a PaymentTransaction instance or payment_transaction_id string." + + # Update PaymentTransaction + transaction.payment_transaction_status = payment_transaction_status + transaction.payment_transaction_notes = payment_transaction_notes + transaction.payment_transaction_raw_data = payment_transaction_raw_data + transaction.payment_transaction_response = payment_transaction_response + transaction.payment_transaction_error = payment_transaction_error + transaction.last_updated_by = user if isinstance(user, User) else None + transaction.save() + + return True, transaction.payment_transaction_id, None + + except Exception as e: + return False, None, str(e) \ No newline at end of file diff --git a/banking_operations/tests.py b/banking_operations/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/banking_operations/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/banking_operations/urls.py b/banking_operations/urls.py new file mode 100644 index 0000000..4aa11c3 --- /dev/null +++ b/banking_operations/urls.py @@ -0,0 +1,24 @@ +from django.urls import path + +from banking_operations.api import ( + PaymentGatewayCreateAPI, + PaymentGatewayListAPI, + PaymentGatewayUpdateAPI, + PaymentGatewayDeleteAPI, + PaymentGatewayCredentialsCreateAPI, + PaymentGatewayCredentialsListAPI, + PaymentGatewayCredentialsUpdateAPI, + PaymentGatewayCredentialsDeleteAPI, +) + + +urlpatterns = [ + path("payment-gateway/create/", PaymentGatewayCreateAPI.as_view(), name="payment_gateway_create"), + path("payment-gateway/list/", PaymentGatewayListAPI.as_view(), name="payment_gateway_list"), + path("payment-gateway/update/", PaymentGatewayUpdateAPI.as_view(), name="payment_gateway_update"), + path("payment-gateway/delete/", PaymentGatewayDeleteAPI.as_view(), name="payment_gateway_delete"), + path("payment-gateway-credentials/create/", PaymentGatewayCredentialsCreateAPI.as_view(), name="payment_gateway_credentials_create"), + path("payment-gateway-credentials/list/", PaymentGatewayCredentialsListAPI.as_view(), name="payment_gateway_credentials_list"), + path("payment-gateway-credentials/update/", PaymentGatewayCredentialsUpdateAPI.as_view(), name="payment_gateway_credentials_update"), + path("payment-gateway-credentials/delete/", PaymentGatewayCredentialsDeleteAPI.as_view(), name="payment_gateway_credentials_delete"), +] diff --git a/banking_operations/views.py b/banking_operations/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/banking_operations/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/bookings/migrations/0001_initial.py b/bookings/migrations/0001_initial.py new file mode 100644 index 0000000..02a934e --- /dev/null +++ b/bookings/migrations/0001_initial.py @@ -0,0 +1,101 @@ +# Generated by Django 4.2.27 on 2026-03-13 16:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('events', '0007_event_gst_percentage_1_event_gst_percentage_2_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Booking', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('booking_id', models.CharField(max_length=250)), + ('quantity', models.IntegerField()), + ('price', models.DecimalField(decimal_places=2, max_digits=10)), + ('created_date', models.DateField(auto_now_add=True)), + ('updated_date', models.DateField(auto_now=True)), + ('transaction_id', models.CharField(blank=True, max_length=250, null=True)), + ], + ), + migrations.CreateModel( + name='TicketMeta', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ticket_name', models.CharField(max_length=250)), + ('maximum_quantity', models.IntegerField()), + ('available_quantity', models.IntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('created_date', models.DateField(auto_now_add=True)), + ('updated_date', models.DateField(auto_now=True)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')), + ], + ), + migrations.CreateModel( + name='TicketType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ticket_type', models.CharField(max_length=250)), + ('ticket_type_description', models.TextField()), + ('ticket_type_quantity', models.IntegerField()), + ('price', models.DecimalField(decimal_places=2, max_digits=10)), + ('is_active', models.BooleanField(default=True)), + ('created_date', models.DateField(auto_now_add=True)), + ('updated_date', models.DateField(auto_now=True)), + ('is_offer', models.BooleanField(default=False)), + ('offer_percentage', models.IntegerField(default=0)), + ('offer_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('offer_start_date', models.DateField(blank=True, null=True)), + ('offer_end_date', models.DateField(blank=True, null=True)), + ('ticket_meta', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.ticketmeta')), + ], + ), + migrations.CreateModel( + name='Ticket', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ticket_id', models.CharField(max_length=250)), + ('is_checked_in', models.BooleanField(default=False)), + ('checked_in_date_time', models.DateTimeField(blank=True, null=True)), + ('booking', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.booking')), + ], + ), + migrations.CreateModel( + name='Cart', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.IntegerField()), + ('price', models.DecimalField(decimal_places=2, max_digits=10)), + ('is_active', models.BooleanField(default=True)), + ('created_date', models.DateField(auto_now_add=True)), + ('updated_date', models.DateField(auto_now=True)), + ('ticket_meta', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.ticketmeta')), + ('ticket_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.tickettype')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='booking', + name='ticket_meta', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.ticketmeta'), + ), + migrations.AddField( + model_name='booking', + name='ticket_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookings.tickettype'), + ), + migrations.AddField( + model_name='booking', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/bookings/models.py b/bookings/models.py index a2beaba..02b4f97 100644 --- a/bookings/models.py +++ b/bookings/models.py @@ -4,10 +4,9 @@ from events.models import Event from accounts.models import User # Create your models here. -class Ticket(models.Model): +class TicketMeta(models.Model): event = models.ForeignKey(Event, on_delete=models.CASCADE) ticket_name = models.CharField(max_length=250) - price_per_ticket = models.DecimalField(max_digits=10, decimal_places=2) maximum_quantity = models.IntegerField() available_quantity = models.IntegerField(default=0) is_active = models.BooleanField(default=True) @@ -19,11 +18,13 @@ class Ticket(models.Model): class TicketType(models.Model): - ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE) + ticket_meta = models.ForeignKey(TicketMeta, on_delete=models.CASCADE) ticket_type = models.CharField(max_length=250) ticket_type_description = models.TextField() - quantity = models.IntegerField() + ticket_type_quantity = models.IntegerField() + price = models.DecimalField(max_digits=10, decimal_places=2) + is_active = models.BooleanField(default=True) created_date = models.DateField(auto_now_add=True) updated_date = models.DateField(auto_now=True) @@ -41,10 +42,11 @@ class TicketType(models.Model): class Cart(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) - ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE) + ticket_meta = models.ForeignKey(TicketMeta, on_delete=models.CASCADE) ticket_type = models.ForeignKey(TicketType, on_delete=models.CASCADE) quantity = models.IntegerField() price = models.DecimalField(max_digits=10, decimal_places=2) + is_active = models.BooleanField(default=True) created_date = models.DateField(auto_now_add=True) updated_date = models.DateField(auto_now=True) @@ -55,7 +57,7 @@ class Cart(models.Model): class Booking(models.Model): booking_id = models.CharField(max_length=250) user = models.ForeignKey(User, on_delete=models.CASCADE) - ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE) + ticket_meta = models.ForeignKey(TicketMeta, on_delete=models.CASCADE) ticket_type = models.ForeignKey(TicketType, on_delete=models.CASCADE) quantity = models.IntegerField() price = models.DecimalField(max_digits=10, decimal_places=2) @@ -70,4 +72,26 @@ class Booking(models.Model): super().save(*args, **kwargs) def __str__(self): - return self.booking_id \ No newline at end of file + return self.booking_id + +class Ticket(models.Model): + booking = models.ForeignKey(Booking, on_delete=models.CASCADE) + ticket_id = models.CharField(max_length=250) + is_checked_in = models.BooleanField(default=False) + checked_in_date_time = models.DateTimeField(blank=True, null=True) + + def __save__(self): + if not self.ticket_id: + self.ticket_id = str(self.booking.ticket_meta.event.name[:3].upper()) + str(uuid.uuid4().hex[:10]).upper() + super().save(*args, **kwargs) + + def __str__(self): + return self.ticket_id + + def check_in(self, ticket_id): + if self.ticket_id == ticket_id: + self.is_checked_in = True + self.checked_in_date_time = datetime.now() + self.save() + return True + return False \ No newline at end of file diff --git a/bookings/services.py b/bookings/services.py new file mode 100644 index 0000000..3d3c752 --- /dev/null +++ b/bookings/services.py @@ -0,0 +1,61 @@ +from typing import List +import uuid + +from django.utils import timezone + +from bookings.models import Booking, Ticket + + +def _generate_ticket_id(booking: Booking) -> str: + """ + Generate a ticket_id based on the event name and a random UUID segment. + + Pattern: + - EVT: first 3 characters of event name (uppercase), or 'EVT' fallback + - RANDOM_HEX: first 10 chars of uuid4 hex (uppercase) + """ + event = getattr(booking.ticket_meta, "event", None) + if event and getattr(event, "name", None): + prefix = (event.name or "EVT")[:3].upper() + else: + prefix = "EVT" + + return prefix + uuid.uuid4().hex[:10].upper() + + +def generate_tickets_for_booking(booking: Booking) -> List[Ticket]: + """ + Generate Ticket instances for a given Booking based on its quantity. + + This function does NOT perform any payment or business-rule validation. + It simply creates one Ticket per quantity on the booking. + + Args: + booking: Booking instance for which tickets should be generated. + + Returns: + List[Ticket]: List of created Ticket instances. + """ + if not isinstance(booking, Booking): + raise TypeError("booking must be a Booking instance") + + if booking.quantity <= 0: + return [] + + tickets: List[Ticket] = [] + for _ in range(booking.quantity): + tickets.append( + Ticket( + booking=booking, + ticket_id=_generate_ticket_id(booking), + is_checked_in=False, + checked_in_date_time=None, + ) + ) + + # Bulk create for efficiency + Ticket.objects.bulk_create(tickets) + + # Refresh from DB to ensure we have primary keys and any defaults + return list[Ticket](Ticket.objects.filter(booking=booking).order_by("id")) + diff --git a/bookings/tickets_view/__init__.py b/bookings/tickets_view/__init__.py index e69de29..f859fc6 100644 --- a/bookings/tickets_view/__init__.py +++ b/bookings/tickets_view/__init__.py @@ -0,0 +1,2 @@ +from . import ticket_meta_type +from . import booking_api \ No newline at end of file diff --git a/bookings/tickets_view/api.py b/bookings/tickets_view/api.py deleted file mode 100644 index 66d0b14..0000000 --- a/bookings/tickets_view/api.py +++ /dev/null @@ -1,301 +0,0 @@ -from django.http import JsonResponse -from django.utils.decorators import method_decorator -from django.views.decorators.csrf import csrf_exempt -from django.forms.models import model_to_dict - -from rest_framework.views import APIView - -from bookings.models import Ticket -from events.models import Event -from mobile_api.utils import validate_token_and_get_user - - -def _ticket_to_dict(ticket): - """ - Helper to serialise a Ticket instance into a simple dict suitable for JSON. - """ - data = model_to_dict( - ticket, - fields=[ - "id", - "ticket_name", - "price_per_ticket", - "maximum_quantity", - "available_quantity", - "is_active", - "created_date", - "updated_date", - ], - ) - data["event_id"] = ticket.event_id - return data - - -@method_decorator(csrf_exempt, name="dispatch") -class TicketCreateAPI(APIView): - """ - Create a new Ticket. - - Expected JSON body (along with token & username used across mobile_api): - { - "token": "...", - "username": "...", - "event_id": 1, - "ticket_name": "VIP", - "price_per_ticket": 1000.0, - "maximum_quantity": 50, - "available_quantity": 50, # optional, defaults to maximum_quantity - "is_active": true # optional, defaults to true - } - """ - - def post(self, request): - try: - user, token, data, error_response = validate_token_and_get_user(request) - if error_response: - return error_response - - event_id = data.get("event_id") - ticket_name = data.get("ticket_name") - price_per_ticket = data.get("price_per_ticket") - maximum_quantity = data.get("maximum_quantity") - available_quantity = data.get("available_quantity") - is_active = data.get("is_active", True) - - if not event_id or not ticket_name or price_per_ticket is None or maximum_quantity is None: - return JsonResponse( - { - "status": "error", - "message": "event_id, ticket_name, price_per_ticket and maximum_quantity are required.", - }, - status=400, - ) - - try: - event = Event.objects.get(id=event_id) - except Event.DoesNotExist: - return JsonResponse( - {"status": "error", "message": "Event not found."}, - status=404, - ) - - try: - price_per_ticket = float(price_per_ticket) - maximum_quantity = int(maximum_quantity) - if available_quantity is not None: - available_quantity = int(available_quantity) - else: - available_quantity = maximum_quantity - except (TypeError, ValueError): - return JsonResponse( - { - "status": "error", - "message": "price_per_ticket, maximum_quantity and available_quantity must be numeric.", - }, - status=400, - ) - - if maximum_quantity <= 0: - return JsonResponse( - { - "status": "error", - "message": "maximum_quantity must be greater than zero.", - }, - status=400, - ) - - ticket = Ticket.objects.create( - event=event, - ticket_name=ticket_name, - price_per_ticket=price_per_ticket, - maximum_quantity=maximum_quantity, - available_quantity=available_quantity, - is_active=bool(is_active), - ) - - return JsonResponse( - {"status": "success", "ticket": _ticket_to_dict(ticket)}, - status=201, - ) - - except Exception as e: - return JsonResponse( - {"status": "error", "message": str(e)}, - status=500, - ) - - -@method_decorator(csrf_exempt, name="dispatch") -class TicketListAPI(APIView): - """ - List tickets, optionally filtered by event_id. - - Expected JSON body: - { - "token": "...", - "username": "...", - "event_id": 1 # optional - } - """ - - def post(self, request): - try: - user, token, data, error_response = validate_token_and_get_user(request) - if error_response: - return error_response - - event_id = data.get("event_id") - - tickets_qs = Ticket.objects.all().order_by("-created_date") - if event_id: - tickets_qs = tickets_qs.filter(event_id=event_id) - - tickets = [_ticket_to_dict(t) for t in tickets_qs] - - return JsonResponse( - {"status": "success", "tickets": tickets}, - status=200, - ) - except Exception as e: - return JsonResponse( - {"status": "error", "message": str(e)}, - status=500, - ) - - -@method_decorator(csrf_exempt, name="dispatch") -class TicketUpdateAPI(APIView): - """ - Update an existing Ticket. - - Expected JSON body: - { - "token": "...", - "username": "...", - "ticket_id": 1, - "ticket_name": "...", # optional - "price_per_ticket": 1000.0, # optional - "maximum_quantity": 50, # optional - "available_quantity": 50, # optional - "is_active": true # optional - } - """ - - def post(self, request): - try: - user, token, data, error_response = validate_token_and_get_user(request) - if error_response: - return error_response - - ticket_id = data.get("ticket_id") - if not ticket_id: - return JsonResponse( - {"status": "error", "message": "ticket_id is required."}, - status=400, - ) - - try: - ticket = Ticket.objects.get(id=ticket_id) - except Ticket.DoesNotExist: - return JsonResponse( - {"status": "error", "message": "Ticket not found."}, - status=404, - ) - - # Optional updates - ticket_name = data.get("ticket_name") - price_per_ticket = data.get("price_per_ticket") - maximum_quantity = data.get("maximum_quantity") - available_quantity = data.get("available_quantity") - is_active = data.get("is_active") - - if ticket_name is not None: - ticket.ticket_name = ticket_name - - try: - if price_per_ticket is not None: - ticket.price_per_ticket = float(price_per_ticket) - if maximum_quantity is not None: - maximum_quantity = int(maximum_quantity) - if maximum_quantity <= 0: - return JsonResponse( - { - "status": "error", - "message": "maximum_quantity must be greater than zero.", - }, - status=400, - ) - ticket.maximum_quantity = maximum_quantity - if available_quantity is not None: - ticket.available_quantity = int(available_quantity) - except (TypeError, ValueError): - return JsonResponse( - { - "status": "error", - "message": "price_per_ticket, maximum_quantity and available_quantity must be numeric.", - }, - status=400, - ) - - if is_active is not None: - ticket.is_active = bool(is_active) - - ticket.save() - - return JsonResponse( - {"status": "success", "ticket": _ticket_to_dict(ticket)}, - status=200, - ) - except Exception as e: - return JsonResponse( - {"status": "error", "message": str(e)}, - status=500, - ) - - -@method_decorator(csrf_exempt, name="dispatch") -class TicketDeleteAPI(APIView): - """ - Delete an existing Ticket. - - Expected JSON body: - { - "token": "...", - "username": "...", - "ticket_id": 1 - } - """ - - def post(self, request): - try: - user, token, data, error_response = validate_token_and_get_user(request) - if error_response: - return error_response - - ticket_id = data.get("ticket_id") - if not ticket_id: - return JsonResponse( - {"status": "error", "message": "ticket_id is required."}, - status=400, - ) - - try: - ticket = Ticket.objects.get(id=ticket_id) - except Ticket.DoesNotExist: - return JsonResponse( - {"status": "error", "message": "Ticket not found."}, - status=404, - ) - - ticket.delete() - - return JsonResponse( - {"status": "success", "message": "Ticket deleted successfully."}, - status=200, - ) - except Exception as e: - return JsonResponse( - {"status": "error", "message": str(e)}, - status=500, - ) - diff --git a/bookings/tickets_view/booking_api.py b/bookings/tickets_view/booking_api.py new file mode 100644 index 0000000..047effe --- /dev/null +++ b/bookings/tickets_view/booking_api.py @@ -0,0 +1,369 @@ +import uuid +from decimal import Decimal +from django.http import JsonResponse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.forms.models import model_to_dict +from django.utils import timezone +from datetime import date + +from rest_framework.views import APIView + +from bookings.models import Cart, TicketType, TicketMeta, Booking, Ticket +from mobile_api.utils import validate_token_and_get_user +from banking_operations.services import transaction_initiate +from eventify_logger.services import log + + +def _cart_to_dict(cart): + """Serialize Cart for JSON.""" + data = model_to_dict( + cart, + fields=["id", "quantity", "price", "created_date", "updated_date"], + ) + data["user_id"] = cart.user_id + data["ticket_meta_id"] = cart.ticket_meta_id + data["ticket_type_id"] = cart.ticket_type_id + return data + + +@method_decorator(csrf_exempt, name="dispatch") +class AddToCartAPI(APIView): + """ + Add TicketType to Cart (when customer clicks plus button). + Body: token, username, ticket_type_id, quantity (optional, defaults to 1). + + If cart item already exists for this user + ticket_type, quantity is incremented. + Price is taken from TicketType (offer_price if offer is active, else price). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + ticket_type_id = data.get("ticket_type_id") + quantity = data.get("quantity", 1) # Default to 1 for plus button click + + if not ticket_type_id: + return JsonResponse( + {"status": "error", "message": "ticket_type_id is required."}, + status=400, + ) + + try: + ticket_type = TicketType.objects.select_related("ticket_meta").get(id=ticket_type_id) + except TicketType.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "TicketType not found."}, + status=404, + ) + + if not ticket_type.is_active: + return JsonResponse( + {"status": "error", "message": "TicketType is not active."}, + status=400, + ) + + try: + quantity = int(quantity) + if quantity <= 0: + return JsonResponse( + {"status": "error", "message": "quantity must be greater than zero."}, + status=400, + ) + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "quantity must be a positive integer."}, + status=400, + ) + + # Determine price: use offer_price if offer is active, else regular price + price = ticket_type.price + if ticket_type.is_offer: + # Check if offer is currently valid (if dates are set) + today = date.today() + offer_valid = True + if ticket_type.offer_start_date and ticket_type.offer_start_date > today: + offer_valid = False + if ticket_type.offer_end_date and ticket_type.offer_end_date < today: + offer_valid = False + + if offer_valid and ticket_type.offer_price > 0: + price = ticket_type.offer_price + + # Check if cart item already exists for this user + ticket_type + cart_item, created = Cart.objects.get_or_create( + user=user, + ticket_type=ticket_type, + defaults={ + "ticket_meta": ticket_type.ticket_meta, + "quantity": quantity, + "price": price, + }, + ) + + if not created: + # Update existing cart item: increment quantity + cart_item.quantity += quantity + cart_item.price = price # Update price in case offer changed + cart_item.save() + + return JsonResponse( + { + "status": "success", + "message": "TicketType added to cart." if created else "Cart item updated.", + "cart_item": _cart_to_dict(cart_item), + }, + status=201 if created else 200, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class DeleteFromCartAPI(APIView): + """ + Remove or decrement TicketType from Cart (when customer clicks minus button). + Body: token, username, ticket_type_id, quantity (optional, defaults to 1). + + Decrements quantity by 1 (or by given quantity). If quantity becomes 0 or less, + the cart item is deleted. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + ticket_type_id = data.get("ticket_type_id") + quantity = data.get("quantity", 1) # Default to 1 for minus button click + + if not ticket_type_id: + return JsonResponse( + {"status": "error", "message": "ticket_type_id is required."}, + status=400, + ) + + try: + quantity = int(quantity) + if quantity <= 0: + return JsonResponse( + {"status": "error", "message": "quantity must be greater than zero."}, + status=400, + ) + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "quantity must be a positive integer."}, + status=400, + ) + + try: + cart_item = Cart.objects.get(user=user, ticket_type_id=ticket_type_id) + except Cart.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "Cart item not found for this ticket type."}, + status=404, + ) + + cart_item.quantity -= quantity + if cart_item.quantity <= 0: + cart_item.delete() + return JsonResponse( + { + "status": "success", + "message": "TicketType removed from cart.", + "cart_item": None, + "removed": True, + }, + status=200, + ) + + cart_item.save() + return JsonResponse( + { + "status": "success", + "message": "Cart quantity updated.", + "cart_item": _cart_to_dict(cart_item), + "removed": False, + }, + status=200, + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class CheckoutAPI(APIView): + """ + Checkout the authenticated user's cart: create one Booking per cart line, + call transaction_initiate in banking_operations, then clear the checked-out cart items. + Body: token, username. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + cart_items = list( + Cart.objects.filter(user=user, is_active=True).select_related( + "ticket_meta", "ticket_type", "ticket_meta__event" + ) + ) + if not cart_items: + return JsonResponse( + {"status": "error", "message": "Cart is empty. Add ticket types before checkout."}, + status=400, + ) + + total_amount = Decimal("0") + created_bookings = [] + cart_ids_to_clear = [] + + for item in cart_items: + event_name = ( + (item.ticket_meta.event.name or "EVT")[:3].upper() + if item.ticket_meta.event_id else "EVT" + ) + booking_id = event_name + uuid.uuid4().hex[:10].upper() + line_total = Decimal(str(item.price)) * item.quantity + total_amount += line_total + + booking = Booking.objects.create( + booking_id=booking_id, + user=user, + ticket_meta=item.ticket_meta, + ticket_type=item.ticket_type, + quantity=item.quantity, + price=item.price, + ) + created_bookings.append(booking) + cart_ids_to_clear.append(item.id) + + reference_id = ",".join(b.booking_id for b in created_bookings) + result = transaction_initiate( + request=request, + user=user, + amount=float(total_amount), + currency="INR", + reference_type="checkout", + reference_id=reference_id, + bookings=created_bookings, + extra_data=None, + ) + + if not result.get("success"): + for b in created_bookings: + b.delete() + return JsonResponse( + { + "status": "error", + "message": result.get("message", "Transaction initiation failed."), + }, + status=502, + ) + + transaction_id = result.get("transaction_id") + if transaction_id: + for b in created_bookings: + b.transaction_id = transaction_id + b.save(update_fields=["transaction_id"]) + + Cart.objects.filter(id__in=cart_ids_to_clear).delete() + + log("info", "Checkout complete", request=request, user=user, logger_data={ + "booking_ids": [b.booking_id for b in created_bookings], + "total_amount": str(total_amount), + }) + response_payload = { + "status": "success", + "message": "Checkout complete. Proceed to payment.", + "booking_ids": [b.booking_id for b in created_bookings], + "total_amount": str(total_amount), + "transaction_id": result.get("transaction_id"), + "payment_url": result.get("payment_url"), + } + return JsonResponse(response_payload, status=200) + + except Exception as e: + log("error", "Checkout exception", request=request, logger_data={"error": str(e)}) + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class CheckInAPI(APIView): + """ + Check-in a ticket by scanning QR code (ticket_id). + Body: token, username, ticket_id (required). + + Looks up Ticket by ticket_id. If found and not already checked in, + sets is_checked_in=True and checked_in_date_time=now. Returns success or + appropriate error (ticket not found / already checked in). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + ticket_id = data.get("ticket_id") + if not ticket_id or not str(ticket_id).strip(): + return JsonResponse( + {"status": "error", "message": "ticket_id is required."}, + status=400, + ) + ticket_id = str(ticket_id).strip() + + try: + ticket = Ticket.objects.select_related( + "booking", "booking__ticket_meta", "booking__ticket_meta__event", "booking__ticket_type" + ).get(ticket_id=ticket_id) + except Ticket.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "Ticket not found."}, + status=404, + ) + + if ticket.is_checked_in: + log("info", "Check-in duplicate - ticket already checked in", request=request, user=user, logger_data={"ticket_id": ticket_id}) + return JsonResponse( + { + "status": "success", + "message": "Ticket already checked in.", + "ticket_id": ticket.ticket_id, + "is_checked_in": True, + "checked_in_date_time": ticket.checked_in_date_time.isoformat() if ticket.checked_in_date_time else None, + "event_name": ticket.booking.ticket_meta.event.name if ticket.booking.ticket_meta_id else None, + "booking_id": ticket.booking.booking_id, + }, + status=200, + ) + + ticket.is_checked_in = True + ticket.checked_in_date_time = timezone.now() + ticket.save(update_fields=["is_checked_in", "checked_in_date_time"]) + + log("info", "Check-in successful", request=request, user=user, logger_data={"ticket_id": ticket_id, "booking_id": ticket.booking.booking_id}) + return JsonResponse( + { + "status": "success", + "message": "Check-in successful.", + "ticket_id": ticket.ticket_id, + "is_checked_in": True, + "checked_in_date_time": ticket.checked_in_date_time.isoformat(), + "event_name": ticket.booking.ticket_meta.event.name if ticket.booking.ticket_meta_id else None, + "booking_id": ticket.booking.booking_id, + }, + status=200, + ) + + except Exception as e: + log("error", "Check-in exception", request=request, logger_data={"error": str(e)}) + return JsonResponse({"status": "error", "message": str(e)}, status=500) diff --git a/bookings/tickets_view/ticket_meta_type.py b/bookings/tickets_view/ticket_meta_type.py new file mode 100644 index 0000000..62eca31 --- /dev/null +++ b/bookings/tickets_view/ticket_meta_type.py @@ -0,0 +1,489 @@ +from django.http import JsonResponse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.forms.models import model_to_dict + +from rest_framework.views import APIView + +from bookings.models import TicketMeta, TicketType +from events.models import Event +from mobile_api.utils import validate_token_and_get_user + + +def _ticket_meta_to_dict(meta): + """Serialize TicketMeta for JSON.""" + data = model_to_dict( + meta, + fields=[ + "id", + "ticket_name", + "maximum_quantity", + "available_quantity", + "is_active", + "created_date", + "updated_date", + ], + ) + data["event_id"] = meta.event_id + return data + + +def _ticket_type_to_dict(tt): + """Serialize TicketType for JSON.""" + data = model_to_dict( + tt, + fields=[ + "id", + "ticket_type", + "ticket_type_description", + "quantity", + "price", + "is_active", + "created_date", + "updated_date", + "is_offer", + "offer_percentage", + "offer_price", + "offer_start_date", + "offer_end_date", + ], + ) + data["ticket_meta_id"] = tt.ticket_meta_id + return data + + +# ---------- TicketMeta (event-level ticket config) CRUD ---------- + + +@method_decorator(csrf_exempt, name="dispatch") +class TicketMetaCreateAPI(APIView): + """ + Create a new TicketMeta (event-level ticket config). + Body: token, username, event_id, ticket_name, maximum_quantity, + available_quantity (optional, defaults to maximum_quantity), is_active (optional, default true). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + event_id = data.get("event_id") + ticket_name = data.get("ticket_name") + maximum_quantity = data.get("maximum_quantity") + available_quantity = data.get("available_quantity") + is_active = data.get("is_active", True) + + if not event_id or not ticket_name or maximum_quantity is None: + return JsonResponse( + { + "status": "error", + "message": "event_id, ticket_name and maximum_quantity are required.", + }, + status=400, + ) + + try: + event = Event.objects.get(id=event_id) + except Event.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "Event not found."}, + status=404, + ) + + try: + maximum_quantity = int(maximum_quantity) + available_quantity = int(available_quantity) if available_quantity is not None else maximum_quantity + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "maximum_quantity and available_quantity must be integers."}, + status=400, + ) + + if maximum_quantity <= 0: + return JsonResponse( + {"status": "error", "message": "maximum_quantity must be greater than zero."}, + status=400, + ) + + meta = TicketMeta.objects.create( + event=event, + ticket_name=ticket_name, + maximum_quantity=maximum_quantity, + available_quantity=available_quantity, + is_active=bool(is_active), + ) + return JsonResponse( + {"status": "success", "ticket_meta": _ticket_meta_to_dict(meta)}, + status=201, + ) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class TicketMetaListAPI(APIView): + """List TicketMeta, optionally filtered by event_id. Body: token, username, event_id (optional).""" + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + event_id = data.get("event_id") + qs = TicketMeta.objects.filter(is_active=True).order_by("-created_date") + if event_id: + qs = qs.filter(event_id=event_id) + items = [_ticket_meta_to_dict(m) for m in qs] + return JsonResponse({"status": "success", "ticket_metas": items}, status=200) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class TicketMetaUpdateAPI(APIView): + """ + Update TicketMeta. Body: token, username, ticket_meta_id (required); + ticket_name, maximum_quantity, available_quantity, is_active (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + pk = data.get("ticket_meta_id") + if not pk: + return JsonResponse({"status": "error", "message": "ticket_meta_id is required."}, status=400) + + try: + meta = TicketMeta.objects.get(id=pk) + except TicketMeta.DoesNotExist: + return JsonResponse({"status": "error", "message": "TicketMeta not found."}, status=404) + + if data.get("ticket_name") is not None: + meta.ticket_name = data["ticket_name"] + if data.get("maximum_quantity") is not None: + try: + val = int(data["maximum_quantity"]) + if val <= 0: + return JsonResponse( + {"status": "error", "message": "maximum_quantity must be greater than zero."}, + status=400, + ) + meta.maximum_quantity = val + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "maximum_quantity must be an integer."}, + status=400, + ) + if data.get("available_quantity") is not None: + try: + meta.available_quantity = int(data["available_quantity"]) + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "available_quantity must be an integer."}, + status=400, + ) + if data.get("is_active") is not None: + meta.is_active = bool(data["is_active"]) + + meta.save() + return JsonResponse({"status": "success", "ticket_meta": _ticket_meta_to_dict(meta)}, status=200) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class TicketMetaDeleteAPI(APIView): + """Delete TicketMeta. Body: token, username, ticket_meta_id.""" + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + pk = data.get("ticket_meta_id") + if not pk: + return JsonResponse({"status": "error", "message": "ticket_meta_id is required."}, status=400) + + try: + meta = TicketMeta.objects.get(id=pk) + except TicketMeta.DoesNotExist: + return JsonResponse({"status": "error", "message": "TicketMeta not found."}, status=404) + + meta.delete() + return JsonResponse({"status": "success", "message": "TicketMeta deleted successfully."}, status=200) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class TicketMetaDeactivateAPI(APIView): + """Deactivate a TicketMeta (set is_active=False). Body: token, username, ticket_meta_id.""" + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + pk = data.get("ticket_meta_id") + if not pk: + return JsonResponse({"status": "error", "message": "ticket_meta_id is required."}, status=400) + + try: + meta = TicketMeta.objects.get(id=pk) + except TicketMeta.DoesNotExist: + return JsonResponse({"status": "error", "message": "TicketMeta not found."}, status=404) + + meta.is_active = False + meta.save() + return JsonResponse( + {"status": "success", "message": "TicketMeta deactivated.", "ticket_meta": _ticket_meta_to_dict(meta)}, + status=200, + ) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +# ---------- TicketType CRUD ---------- + + +@method_decorator(csrf_exempt, name="dispatch") +class TicketTypeCreateAPI(APIView): + """ + Create a new TicketType. + Body: token, username, ticket_meta_id, ticket_type, ticket_type_description, quantity, price (required); + is_active (optional, default true); is_offer, offer_percentage, offer_price, offer_start_date, offer_end_date (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + ticket_meta_id = data.get("ticket_meta_id") + ticket_type_name = data.get("ticket_type") + ticket_type_description = data.get("ticket_type_description") + quantity = data.get("quantity") + price = data.get("price") + is_active = data.get("is_active", True) + is_offer = data.get("is_offer", False) + offer_percentage = data.get("offer_percentage", 0) + offer_price = data.get("offer_price", 0) + offer_start_date = data.get("offer_start_date") + offer_end_date = data.get("offer_end_date") + + if not ticket_meta_id or not ticket_type_name or quantity is None or price is None: + return JsonResponse( + { + "status": "error", + "message": "ticket_meta_id, ticket_type, ticket_type_description, quantity and price are required.", + }, + status=400, + ) + + try: + ticket_meta = TicketMeta.objects.get(id=ticket_meta_id) + except TicketMeta.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "TicketMeta not found."}, + status=404, + ) + + try: + quantity = int(quantity) + price = float(price) + offer_percentage = int(offer_percentage) if offer_percentage is not None else 0 + offer_price = float(offer_price) if offer_price is not None else 0 + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "quantity, price, offer_percentage and offer_price must be numeric."}, + status=400, + ) + + if quantity <= 0: + return JsonResponse( + {"status": "error", "message": "quantity must be greater than zero."}, + status=400, + ) + + tt = TicketType.objects.create( + ticket_meta=ticket_meta, + ticket_type=ticket_type_name, + ticket_type_description=ticket_type_description or "", + quantity=quantity, + price=price, + is_active=bool(is_active), + is_offer=bool(is_offer), + offer_percentage=offer_percentage, + offer_price=offer_price, + offer_start_date=offer_start_date, + offer_end_date=offer_end_date, + ) + return JsonResponse( + {"status": "success", "ticket_type": _ticket_type_to_dict(tt)}, + status=201, + ) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class TicketTypeListAPI(APIView): + """List TicketType, optionally filtered by ticket_meta_id. Body: token, username, ticket_meta_id (optional).""" + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + ticket_meta_id = data.get("ticket_meta_id") + qs = TicketType.objects.filter(is_active=True).order_by("-created_date") + if ticket_meta_id: + qs = qs.filter(ticket_meta_id=ticket_meta_id) + items = [_ticket_type_to_dict(tt) for tt in qs] + return JsonResponse({"status": "success", "ticket_types": items}, status=200) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class TicketTypeUpdateAPI(APIView): + """ + Update TicketType. Body: token, username, ticket_type_id (required); + ticket_type, ticket_type_description, quantity, price, is_active, + is_offer, offer_percentage, offer_price, offer_start_date, offer_end_date (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + pk = data.get("ticket_type_id") + if not pk: + return JsonResponse({"status": "error", "message": "ticket_type_id is required."}, status=400) + + try: + tt = TicketType.objects.get(id=pk) + except TicketType.DoesNotExist: + return JsonResponse({"status": "error", "message": "TicketType not found."}, status=404) + + if data.get("ticket_type") is not None: + tt.ticket_type = data["ticket_type"] + if data.get("ticket_type_description") is not None: + tt.ticket_type_description = data["ticket_type_description"] + if data.get("quantity") is not None: + try: + val = int(data["quantity"]) + if val <= 0: + return JsonResponse( + {"status": "error", "message": "quantity must be greater than zero."}, + status=400, + ) + tt.quantity = val + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "quantity must be an integer."}, + status=400, + ) + if data.get("price") is not None: + try: + tt.price = float(data["price"]) + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "price must be numeric."}, + status=400, + ) + if data.get("is_active") is not None: + tt.is_active = bool(data["is_active"]) + if data.get("is_offer") is not None: + tt.is_offer = bool(data["is_offer"]) + if data.get("offer_percentage") is not None: + try: + tt.offer_percentage = int(data["offer_percentage"]) + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "offer_percentage must be an integer."}, + status=400, + ) + if data.get("offer_price") is not None: + try: + tt.offer_price = float(data["offer_price"]) + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "offer_price must be numeric."}, + status=400, + ) + if "offer_start_date" in data: + tt.offer_start_date = data["offer_start_date"] + if "offer_end_date" in data: + tt.offer_end_date = data["offer_end_date"] + + tt.save() + return JsonResponse({"status": "success", "ticket_type": _ticket_type_to_dict(tt)}, status=200) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class TicketTypeDeactivateAPI(APIView): + """Deactivate a TicketType (set is_active=False). Body: token, username, ticket_type_id.""" + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + pk = data.get("ticket_type_id") + if not pk: + return JsonResponse({"status": "error", "message": "ticket_type_id is required."}, status=400) + + try: + tt = TicketType.objects.get(id=pk) + except TicketType.DoesNotExist: + return JsonResponse({"status": "error", "message": "TicketType not found."}, status=404) + + tt.is_active = False + tt.save() + return JsonResponse( + {"status": "success", "message": "TicketType deactivated.", "ticket_type": _ticket_type_to_dict(tt)}, + status=200, + ) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class TicketTypeDeleteAPI(APIView): + """Delete TicketType. Body: token, username, ticket_type_id.""" + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + pk = data.get("ticket_type_id") + if not pk: + return JsonResponse({"status": "error", "message": "ticket_type_id is required."}, status=400) + + try: + tt = TicketType.objects.get(id=pk) + except TicketType.DoesNotExist: + return JsonResponse({"status": "error", "message": "TicketType not found."}, status=404) + + tt.delete() + return JsonResponse({"status": "success", "message": "TicketType deleted successfully."}, status=200) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) diff --git a/bookings/urls.py b/bookings/urls.py index c5589a9..8a4a4ea 100644 --- a/bookings/urls.py +++ b/bookings/urls.py @@ -1,17 +1,34 @@ from django.urls import path -from bookings.tickets_view.api import ( - TicketCreateAPI, - TicketListAPI, - TicketUpdateAPI, - TicketDeleteAPI, +from bookings.tickets_view.ticket_meta_type import ( + TicketMetaCreateAPI, + TicketMetaListAPI, + TicketMetaUpdateAPI, + TicketMetaDeleteAPI, + TicketMetaDeactivateAPI, + TicketTypeCreateAPI, + TicketTypeListAPI, + TicketTypeUpdateAPI, + TicketTypeDeleteAPI, + TicketTypeDeactivateAPI, ) +from bookings.tickets_view.booking_api import AddToCartAPI, DeleteFromCartAPI, CheckoutAPI, CheckInAPI urlpatterns = [ - path("tickets/create/", TicketCreateAPI.as_view(), name="ticket_create"), - path("tickets/list/", TicketListAPI.as_view(), name="ticket_list"), - path("tickets/update/", TicketUpdateAPI.as_view(), name="ticket_update"), - path("tickets/delete/", TicketDeleteAPI.as_view(), name="ticket_delete"), + path("ticket-meta/create/", TicketMetaCreateAPI.as_view(), name="ticket_meta_create"), + path("ticket-meta/list/", TicketMetaListAPI.as_view(), name="ticket_meta_list"), + path("ticket-meta/update/", TicketMetaUpdateAPI.as_view(), name="ticket_meta_update"), + path("ticket-meta/delete/", TicketMetaDeleteAPI.as_view(), name="ticket_meta_delete"), + path("ticket-meta/deactivate/", TicketMetaDeactivateAPI.as_view(), name="ticket_meta_deactivate"), + path("ticket-type/create/", TicketTypeCreateAPI.as_view(), name="ticket_type_create"), + path("ticket-type/list/", TicketTypeListAPI.as_view(), name="ticket_type_list"), + path("ticket-type/update/", TicketTypeUpdateAPI.as_view(), name="ticket_type_update"), + path("ticket-type/delete/", TicketTypeDeleteAPI.as_view(), name="ticket_type_delete"), + path("ticket-type/deactivate/", TicketTypeDeactivateAPI.as_view(), name="ticket_type_deactivate"), + path("cart/add/", AddToCartAPI.as_view(), name="add_to_cart"), + path("cart/delete/", DeleteFromCartAPI.as_view(), name="delete_from_cart"), + path("checkout/", CheckoutAPI.as_view(), name="checkout"), + path("check-in/", CheckInAPI.as_view(), name="check_in"), ] diff --git a/db_1.sqlite3 b/db_1.sqlite3 deleted file mode 100644 index 948fdc9..0000000 Binary files a/db_1.sqlite3 and /dev/null differ diff --git a/eventify/settings.py b/eventify/settings.py index 24b3811..6f21b6a 100644 --- a/eventify/settings.py +++ b/eventify/settings.py @@ -22,12 +22,16 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'eventify_logger', 'master_data', 'events', 'accounts', + 'partner', 'templatetags', 'mobile_api', 'web_api', + 'bookings', + 'banking_operations', 'rest_framework', 'rest_framework.authtoken' ] @@ -42,6 +46,7 @@ MIDDLEWARE = [ 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'eventify_logger.middleware.EventifyLoggingMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'corsheaders.middleware.CorsMiddleware', diff --git a/eventify/urls.py b/eventify/urls.py index 326e805..b7f32e4 100644 --- a/eventify/urls.py +++ b/eventify/urls.py @@ -32,6 +32,8 @@ urlpatterns = [ path('events/', include('events.urls')), path('accounts/', include('accounts.urls')), path('bookings/', include('bookings.urls')), + path('partner/', include('partner.urls')), + path('banking/', include('banking_operations.urls')), path('api/', include('mobile_api.urls')), # path('web-api/', include('web_api.urls')), ] diff --git a/eventify_logger/__init__.py b/eventify_logger/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eventify_logger/admin.py b/eventify_logger/admin.py new file mode 100644 index 0000000..cf920e1 --- /dev/null +++ b/eventify_logger/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from .models import EventifyLogger + + +@admin.register(EventifyLogger) +class EventifyLoggerAdmin(admin.ModelAdmin): + list_display = ("logger_type", "logger_message", "logged_user", "logger_created_at") + list_filter = ("logger_type", "logger_created_at") + search_fields = ("logger_message",) + readonly_fields = ("logger_created_at",) diff --git a/eventify_logger/apps.py b/eventify_logger/apps.py new file mode 100644 index 0000000..13b4f45 --- /dev/null +++ b/eventify_logger/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EventifyLoggerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'eventify_logger' diff --git a/eventify_logger/middleware.py b/eventify_logger/middleware.py new file mode 100644 index 0000000..d836a0f --- /dev/null +++ b/eventify_logger/middleware.py @@ -0,0 +1,28 @@ +""" +Request logging middleware - logs every HTTP request to EventifyLogger. +""" +from eventify_logger.services import log + + +class EventifyLoggingMiddleware: + """Log each request (method, path, status) after response.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + try: + status = getattr(response, "status_code", 0) + if 500 <= status < 600: + logger_type = "error" + elif 400 <= status < 500: + logger_type = "warning" + else: + logger_type = "info" + message = f"{request.method} {request.path} -> {status}" + logger_data = {"path": request.path, "method": request.method, "status_code": status} + log(logger_type=logger_type, logger_message=message, request=request, logger_data=logger_data) + except Exception: + pass + return response diff --git a/eventify_logger/migrations/0001_initial.py b/eventify_logger/migrations/0001_initial.py new file mode 100644 index 0000000..a8587bc --- /dev/null +++ b/eventify_logger/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.27 on 2026-03-09 04:26 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='EventifyLogger', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('logger_type', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('critical', 'Critical')], max_length=250)), + ('logger_message', models.TextField()), + ('logger_data', models.TextField(blank=True, null=True)), + ('logger_created_at', models.DateTimeField(auto_now_add=True)), + ('logged_ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('logged_user_device', models.CharField(blank=True, max_length=250, null=True)), + ('logged_user_browser', models.CharField(blank=True, max_length=250, null=True)), + ('logged_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/eventify_logger/migrations/__init__.py b/eventify_logger/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eventify_logger/models.py b/eventify_logger/models.py new file mode 100644 index 0000000..1e6bed1 --- /dev/null +++ b/eventify_logger/models.py @@ -0,0 +1,27 @@ +from django.conf import settings +from django.db import models + + +class EventifyLogger(models.Model): + logger_type = models.CharField(max_length=250, choices=[ + ('info', 'Info'), + ('warning', 'Warning'), + ('error', 'Error'), + ('critical', 'Critical'), + ]) + logger_message = models.TextField() + logger_data = models.TextField(blank=True, null=True) + logger_created_at = models.DateTimeField(auto_now_add=True) + logged_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + ) + logged_ip_address = models.GenericIPAddressField(blank=True, null=True) + logged_user_device = models.CharField(max_length=250, blank=True, null=True) + logged_user_browser = models.CharField(max_length=250, blank=True, null=True) + + def __str__(self): + user_str = str(self.logged_user) if self.logged_user else "anonymous" + return f"{user_str}-{self.logger_type} - {self.logger_message}" \ No newline at end of file diff --git a/eventify_logger/services.py b/eventify_logger/services.py new file mode 100644 index 0000000..4ec39a5 --- /dev/null +++ b/eventify_logger/services.py @@ -0,0 +1,58 @@ +""" +Central logging service for EventifyLogger. +""" +import json + +from eventify_logger.models import EventifyLogger + + +def _get_client_ip(request): + """Extract client IP from request.""" + if not request: + return None + x_forwarded = request.META.get("HTTP_X_FORWARDED_FOR") + if x_forwarded: + return x_forwarded.split(",")[0].strip() or None + return request.META.get("REMOTE_ADDR") + + +def _get_user_agent(request): + """Extract User-Agent from request.""" + if not request: + return None + return request.META.get("HTTP_USER_AGENT", "") + + +def log(logger_type, logger_message, request=None, user=None, logger_data=None): + """ + Create an EventifyLogger record. + + Args: + logger_type: 'info' | 'warning' | 'error' | 'critical' + logger_message: str + request: optional HttpRequest (used for IP, user-agent, user if not provided) + user: optional User (overrides request.user) + logger_data: optional str or dict (dict will be JSON-serialized) + """ + try: + resolved_user = user + if resolved_user is None and request and hasattr(request, "user"): + resolved_user = getattr(request.user, "is_authenticated", False) and request.user or None + + ip_address = _get_client_ip(request) if request else None + user_agent = _get_user_agent(request) if request else None + + if isinstance(logger_data, dict): + logger_data = json.dumps(logger_data) + + EventifyLogger.objects.create( + logger_type=logger_type, + logger_message=str(logger_message)[:10000], # cap message length + logger_data=logger_data[:10000] if logger_data else None, # cap data length + logged_user=resolved_user, + logged_ip_address=ip_address, + logged_user_device=None, # defer UA parsing + logged_user_browser=user_agent[:250] if user_agent else None, + ) + except Exception: + pass # Never let logging break the app diff --git a/eventify_logger/tests.py b/eventify_logger/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/eventify_logger/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/eventify_logger/views.py b/eventify_logger/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/eventify_logger/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/events/api.py b/events/api.py new file mode 100644 index 0000000..ca23678 --- /dev/null +++ b/events/api.py @@ -0,0 +1,246 @@ +from django.http import JsonResponse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.forms.models import model_to_dict +from rest_framework.views import APIView +from datetime import datetime + +from events.models import Event +from master_data.models import EventType +from mobile_api.utils import validate_token_and_get_user +from eventify_logger.services import log + + +def _event_to_dict(event, request=None): + """Serialize Event for JSON.""" + data = model_to_dict( + event, + fields=[ + "id", + "name", + "description", + "start_date", + "end_date", + "start_time", + "end_time", + "all_year_event", + "latitude", + "longitude", + "pincode", + "district", + "state", + "place", + "venue_name", + "event_status", + "cancelled_reason", + "important_information", + "source", + "created_date", + ], + ) + # Add event_type info + data["event_type"] = { + "id": event.event_type.id, + "event_type": event.event_type.event_type, + } + if event.event_type.event_type_icon: + if request: + data["event_type"]["event_type_icon"] = request.build_absolute_uri(event.event_type.event_type_icon.url) + else: + data["event_type"]["event_type_icon"] = event.event_type.event_type_icon.url + else: + data["event_type"]["event_type_icon"] = None + + return data + + +@method_decorator(csrf_exempt, name="dispatch") +class EventCreateAPI(APIView): + """ + Create Event API. + Body: token, username (required); + name, description, latitude, longitude, pincode, place, event_type_id (required); + start_date, end_date, start_time, end_time, all_year_event, venue_name, + event_status, cancelled_reason, important_information, source, district, state (optional). + Returns: created event data. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + # Extract required fields + name = data.get("name") + description = data.get("description") + latitude = data.get("latitude") + longitude = data.get("longitude") + pincode = data.get("pincode") + place = data.get("place") + event_type_id = data.get("event_type_id") or data.get("event_type") + + # Validate required fields + if not all([name, description, latitude, longitude, pincode, place, event_type_id]): + return JsonResponse( + { + "status": "error", + "message": "name, description, latitude, longitude, pincode, place, and event_type_id are required.", + }, + status=400, + ) + + # Validate event_type exists + try: + event_type = EventType.objects.get(id=event_type_id) + except EventType.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "EventType not found."}, + status=404, + ) + + # Validate latitude and longitude + try: + latitude = float(latitude) + if latitude < -90 or latitude > 90: + return JsonResponse( + {"status": "error", "message": "latitude must be between -90 and 90."}, + status=400, + ) + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "latitude must be numeric."}, + status=400, + ) + + try: + longitude = float(longitude) + if longitude < -180 or longitude > 180: + return JsonResponse( + {"status": "error", "message": "longitude must be between -180 and 180."}, + status=400, + ) + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "longitude must be numeric."}, + status=400, + ) + + # Handle all_year_event + all_year_event = data.get("all_year_event", False) + if isinstance(all_year_event, str): + all_year_event = all_year_event.lower() in ['true', '1', 'yes', 'on'] + + # Handle dates and times - clear if all_year_event is True + start_date = None + end_date = None + start_time = None + end_time = None + + if not all_year_event: + # Parse start_date + if data.get("start_date"): + try: + start_date = datetime.strptime(data["start_date"], "%Y-%m-%d").date() + except ValueError: + return JsonResponse( + {"status": "error", "message": "Invalid start_date format. Expected YYYY-MM-DD."}, + status=400, + ) + + # Parse end_date + if data.get("end_date"): + try: + end_date = datetime.strptime(data["end_date"], "%Y-%m-%d").date() + except ValueError: + return JsonResponse( + {"status": "error", "message": "Invalid end_date format. Expected YYYY-MM-DD."}, + status=400, + ) + + # Parse start_time + if data.get("start_time"): + try: + start_time = datetime.strptime(data["start_time"], "%H:%M:%S").time() + except ValueError: + try: + start_time = datetime.strptime(data["start_time"], "%H:%M").time() + except ValueError: + return JsonResponse( + {"status": "error", "message": "Invalid start_time format. Expected HH:MM or HH:MM:SS."}, + status=400, + ) + + # Parse end_time + if data.get("end_time"): + try: + end_time = datetime.strptime(data["end_time"], "%H:%M:%S").time() + except ValueError: + try: + end_time = datetime.strptime(data["end_time"], "%H:%M").time() + except ValueError: + return JsonResponse( + {"status": "error", "message": "Invalid end_time format. Expected HH:MM or HH:MM:SS."}, + status=400, + ) + + # Validate event_status if provided + event_status = data.get("event_status", "pending") + valid_statuses = ['created', 'cancelled', 'pending', 'completed', 'postponed'] + if event_status not in valid_statuses: + return JsonResponse( + { + "status": "error", + "message": f"Invalid event_status. Must be one of: {', '.join(valid_statuses)}", + }, + status=400, + ) + + # Validate source if provided + source = data.get("source", "official") + valid_sources = ['official', 'community'] + if source not in valid_sources: + return JsonResponse( + { + "status": "error", + "message": f"Invalid source. Must be one of: {', '.join(valid_sources)}", + }, + status=400, + ) + + # Create event + event = Event.objects.create( + name=name, + description=description, + start_date=start_date, + end_date=end_date, + start_time=start_time, + end_time=end_time, + all_year_event=all_year_event, + latitude=latitude, + longitude=longitude, + pincode=pincode, + district=data.get("district", ""), + state=data.get("state", ""), + place=place, + venue_name=data.get("venue_name", ""), + event_type=event_type, + event_status=event_status, + cancelled_reason=data.get("cancelled_reason", "NA"), + important_information=data.get("important_information", ""), + source=source, + ) + + log("info", "Event created", request=request, user=user, logger_data={"event_id": event.id, "event_name": name}) + return JsonResponse( + { + "status": "success", + "message": "Event created successfully.", + "event": _event_to_dict(event, request), + }, + status=201, + ) + + except Exception as e: + log("error", "Event create exception", request=request, logger_data={"error": str(e)}) + return JsonResponse({"status": "error", "message": str(e)}, status=500) diff --git a/events/migrations/0007_event_gst_percentage_1_event_gst_percentage_2_and_more.py b/events/migrations/0007_event_gst_percentage_1_event_gst_percentage_2_and_more.py new file mode 100644 index 0000000..0bc59b8 --- /dev/null +++ b/events/migrations/0007_event_gst_percentage_1_event_gst_percentage_2_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.27 on 2026-03-13 16:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0006_alter_event_source'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='gst_percentage_1', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='event', + name='gst_percentage_2', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='event', + name='include_gst', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='event', + name='event_status', + field=models.CharField(choices=[('created', 'Created'), ('cancelled', 'Cancelled'), ('pending', 'Pending'), ('completed', 'Completed'), ('postponed', 'Postponed'), ('published', 'Published'), ('live', 'Live'), ('flagged', 'Flagged')], default='pending', max_length=250), + ), + ] diff --git a/events/migrations/0008_event_is_partner_event_event_partner.py b/events/migrations/0008_event_is_partner_event_event_partner.py new file mode 100644 index 0000000..67c52e2 --- /dev/null +++ b/events/migrations/0008_event_is_partner_event_event_partner.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.27 on 2026-03-14 15:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('partner', '0001_initial'), + ('events', '0007_event_gst_percentage_1_event_gst_percentage_2_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='is_partner_event', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='event', + name='partner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='partner.partner'), + ), + ] diff --git a/events/models.py b/events/models.py index 3373701..b37cc01 100644 --- a/events/models.py +++ b/events/models.py @@ -1,6 +1,7 @@ from random import choices from django.db import models from master_data.models import EventType +from partner.models import Partner class Event(models.Model): @@ -21,10 +22,16 @@ class Event(models.Model): place = models.CharField(max_length=200) is_bookable = models.BooleanField(default=False) + include_gst = models.BooleanField(default=False) + gst_percentage_1 = models.IntegerField(default=0) + gst_percentage_2 = models.IntegerField(default=0) is_eventify_event = models.BooleanField(default=True) outside_event_url = models.URLField(default='NA') + is_partner_event = models.BooleanField(default=False) + partner = models.ForeignKey(Partner, on_delete=models.CASCADE, blank=True, null=True) + event_type = models.ForeignKey(EventType, on_delete=models.CASCADE) event_status = models.CharField(max_length=250, choices=[ ('created', 'Created'), @@ -32,6 +39,9 @@ class Event(models.Model): ('pending', 'Pending'), ('completed', 'Completed'), ('postponed', 'Postponed'), + ('published', 'Published'), + ('live', 'Live'), + ('flagged', 'Flagged'), ], default='pending') cancelled_reason = models.TextField(default='NA') diff --git a/events/urls.py b/events/urls.py index 46934f3..ada8656 100644 --- a/events/urls.py +++ b/events/urls.py @@ -1,5 +1,6 @@ from django.urls import path from . import views +from . import api app_name = 'events' @@ -14,3 +15,8 @@ urlpatterns = [ path('/images//delete/', views.delete_event_image, name='delete_event_image'), path('/images//primary/', views.set_primary_image, name='set_primary_image'), ] + +# Event API URLs +urlpatterns += [ + path('api/create/', api.EventCreateAPI.as_view(), name='event_api_create'), +] diff --git a/events/views.py b/events/views.py index 4a3711b..890b10a 100644 --- a/events/views.py +++ b/events/views.py @@ -10,8 +10,6 @@ from django.shortcuts import get_object_or_404, redirect, render from django.contrib.auth.decorators import login_required -from django.http import JsonResponse -from .models import Event class EventListView(LoginRequiredMixin, generic.ListView): diff --git a/mobile_api/urls.py b/mobile_api/urls.py index c6e917f..e924727 100644 --- a/mobile_api/urls.py +++ b/mobile_api/urls.py @@ -2,7 +2,7 @@ from django.urls import path from .views import * -# User URLS +# Customer URLS urlpatterns = [ path('user/register/', RegisterView.as_view(), name='json_register'), path('user/login/', LoginView.as_view(), name='json_login'), diff --git a/mobile_api/views/user.py b/mobile_api/views/user.py index 261fe2a..229d81d 100644 --- a/mobile_api/views/user.py +++ b/mobile_api/views/user.py @@ -11,6 +11,7 @@ from django.contrib.auth import logout from mobile_api.utils import validate_token_and_get_user from utils.errors_json_convertor import simplify_form_errors from accounts.models import User +from eventify_logger.services import log @method_decorator(csrf_exempt, name='dispatch') @@ -22,9 +23,12 @@ class RegisterView(View): if form.is_valid(): user = form.save() token, _ = Token.objects.get_or_create(user=user) + log("info", "API user registration", request=request, user=user) return JsonResponse({'message': 'User registered successfully', 'token': token.key}, status=201) + log("warning", "API registration failed", request=request, logger_data=dict(errors=form.errors)) return JsonResponse({'errors': form.errors}, status=400) except Exception as e: + log("error", "API registration exception", request=request, logger_data={"error": str(e)}) return JsonResponse({'error': str(e)}, status=500) @@ -47,6 +51,7 @@ class WebRegisterView(View): user = form.save() token, _ = Token.objects.get_or_create(user=user) print('3') + log("info", "Web user registration", request=request, user=user) response = { 'message': 'User registered successfully', 'token': token.key, @@ -55,8 +60,10 @@ class WebRegisterView(View): 'phone_number': user.phone_number, } return JsonResponse(response, status=201) + log("warning", "Web registration failed", request=request, logger_data=dict(errors=form.errors)) return JsonResponse({'errors': form.errors}, status=400) except Exception as e: + log("error", "Web registration exception", request=request, logger_data={"error": str(e)}) return JsonResponse({'error': str(e)}, status=500) @@ -68,11 +75,12 @@ class LoginView(View): data = json.loads(request.body) form = LoginForm(data) print('1') - if form.is_valid(): + if form.is_valid(): print('2') user = form.cleaned_data['user'] token, _ = Token.objects.get_or_create(user=user) print('3') + log("info", "API login", request=request, user=user) response = { 'message': 'Login successful', 'token': token.key, @@ -94,9 +102,11 @@ class LoginView(View): print('4') print(response) return JsonResponse(response, status=200) - + + log("warning", "API login failed", request=request, logger_data=dict(errors=form.errors)) return JsonResponse(simplify_form_errors(form), status=401) except Exception as e: + log("error", "API login exception", request=request, logger_data={"error": str(e)}) return JsonResponse({'error': str(e)}, status=500) @@ -126,6 +136,7 @@ class LogoutView(View): if error_response: return error_response + log("info", "API logout", request=request, user=user) # 🔍 Call Django's built-in logout logout(request) @@ -138,6 +149,7 @@ class LogoutView(View): }) except Exception as e: + log("error", "API logout exception", request=request, logger_data={"error": str(e)}) return JsonResponse({"status": "error", "message": str(e)}, status=500) diff --git a/partner/__init__.py b/partner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/partner/admin.py b/partner/admin.py new file mode 100644 index 0000000..21b469e --- /dev/null +++ b/partner/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin +from .models import Partner + + +@admin.register(Partner) +class PartnerAdmin(admin.ModelAdmin): + list_display = ('name', 'partner_type', 'primary_contact_person_name', 'primary_contact_person_email', 'is_kyc_compliant', 'kyc_compliance_status') + list_filter = ('partner_type', 'is_kyc_compliant', 'kyc_compliance_status') + search_fields = ('name', 'primary_contact_person_name', 'primary_contact_person_email', 'primary_contact_person_phone') + fieldsets = ( + ('Basic Information', { + 'fields': ('name', 'partner_type') + }), + ('Contact Information', { + 'fields': ('primary_contact_person_name', 'primary_contact_person_email', 'primary_contact_person_phone', 'website_url') + }), + ('Address', { + 'fields': ('address', 'city', 'state', 'country', 'pincode', 'latitude', 'longitude') + }), + ('KYC Compliance', { + 'fields': ('is_kyc_compliant', 'kyc_compliance_status', 'kyc_compliance_reason', 'kyc_compliance_document_type', + 'kyc_compliance_document_other_type', 'kyc_compliance_document_file', 'kyc_compliance_document_number') + }), + ) diff --git a/partner/api.py b/partner/api.py new file mode 100644 index 0000000..3bde5c8 --- /dev/null +++ b/partner/api.py @@ -0,0 +1,1118 @@ +from django.http import JsonResponse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.forms.models import model_to_dict +from django.contrib.auth import get_user_model +from django.db import models as django_models +from django.utils import timezone +from datetime import timedelta + +from rest_framework.views import APIView + +from partner.models import Partner +from mobile_api.utils import validate_token_and_get_user +from eventify_logger.services import log + +User = get_user_model() + + +def _partner_to_dict(partner, request=None): + """Serialize Partner for JSON.""" + data = model_to_dict( + partner, + fields=[ + "id", + "name", + "partner_type", + "primary_contact_person_name", + "primary_contact_person_email", + "primary_contact_person_phone", + "status", + "address", + "city", + "state", + "country", + "website_url", + "pincode", + "latitude", + "longitude", + "is_kyc_compliant", + "kyc_compliance_status", + "kyc_compliance_reason", + "kyc_compliance_document_type", + "kyc_compliance_document_other_type", + "kyc_compliance_document_number", + ], + ) + # Add document file URL if exists + if partner.kyc_compliance_document_file: + if request: + data["kyc_compliance_document_file"] = request.build_absolute_uri(partner.kyc_compliance_document_file.url) + else: + data["kyc_compliance_document_file"] = partner.kyc_compliance_document_file.url + else: + data["kyc_compliance_document_file"] = None + return data + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerCreateAPI(APIView): + """ + Create a new Partner. + Body: token, username, name, partner_type, primary_contact_person_name, + primary_contact_person_email, primary_contact_person_phone (required); + address, city, state, country, website_url, pincode, latitude, longitude, + is_kyc_compliant, kyc_compliance_status, kyc_compliance_reason, + kyc_compliance_document_type, kyc_compliance_document_other_type, + kyc_compliance_document_number (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + name = data.get("name") + partner_type = data.get("partner_type") + primary_contact_person_name = data.get("primary_contact_person_name") + primary_contact_person_email = data.get("primary_contact_person_email") + primary_contact_person_phone = data.get("primary_contact_person_phone") + + if not all([name, partner_type, primary_contact_person_name, primary_contact_person_email, primary_contact_person_phone]): + return JsonResponse( + { + "status": "error", + "message": "name, partner_type, primary_contact_person_name, primary_contact_person_email, and primary_contact_person_phone are required.", + }, + status=400, + ) + + # Validate partner_type + valid_partner_types = [choice[1] for choice in Partner._meta.get_field("partner_type").choices] + print(valid_partner_types) + if partner_type not in valid_partner_types: + return JsonResponse( + { + "status": "error", + "message": f"Invalid partner_type. Must be one of: {', '.join(valid_partner_types)}", + }, + status=400, + ) + + partner = Partner.objects.create( + name=name, + partner_type=partner_type, + primary_contact_person_name=primary_contact_person_name, + primary_contact_person_email=primary_contact_person_email, + primary_contact_person_phone=primary_contact_person_phone, + address=data.get("address"), + city=data.get("city"), + state=data.get("state"), + country=data.get("country"), + website_url=data.get("website_url"), + pincode=data.get("pincode"), + latitude=data.get("latitude"), + longitude=data.get("longitude"), + is_kyc_compliant=data.get("is_kyc_compliant", False), + kyc_compliance_status=data.get("kyc_compliance_status", "pending"), + kyc_compliance_reason=data.get("kyc_compliance_reason"), + kyc_compliance_document_type=data.get("kyc_compliance_document_type"), + kyc_compliance_document_other_type=data.get("kyc_compliance_document_other_type"), + kyc_compliance_document_number=data.get("kyc_compliance_document_number"), + ) + + # Handle file upload if provided + if "kyc_compliance_document_file" in request.FILES: + partner.kyc_compliance_document_file = request.FILES["kyc_compliance_document_file"] + partner.save() + + log("info", "Partner created", request=request, user=user, logger_data={"partner_id": partner.id, "partner_name": name}) + return JsonResponse( + {"status": "success", "partner": _partner_to_dict(partner, request)}, + status=201, + ) + except Exception as e: + log("error", "Partner create exception", request=request, logger_data={"error": str(e)}) + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerListAPI(APIView): + """ + List Partners, optionally filtered by partner_type or kyc_compliance_status. + Body: token, username, partner_type (optional), kyc_compliance_status (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + qs = Partner.objects.all().order_by("-id") + + partner_type = data.get("partner_type") + if partner_type: + qs = qs.filter(partner_type=partner_type) + + kyc_compliance_status = data.get("kyc_compliance_status") + if kyc_compliance_status: + qs = qs.filter(kyc_compliance_status=kyc_compliance_status) + + partners = [_partner_to_dict(p, request) for p in qs] + return JsonResponse({"status": "success", "partners": partners}, status=200) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerUpdateAPI(APIView): + """ + Update an existing Partner. + Body: token, username, partner_id (required); + name, partner_type, primary_contact_person_name, primary_contact_person_email, + primary_contact_person_phone, address, city, state, country, website_url, + pincode, latitude, longitude, is_kyc_compliant, kyc_compliance_status, + kyc_compliance_reason, kyc_compliance_document_type, + kyc_compliance_document_other_type, kyc_compliance_document_number (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + partner_id = data.get("partner_id") + if not partner_id: + return JsonResponse( + {"status": "error", "message": "partner_id is required."}, + status=400, + ) + + try: + partner = Partner.objects.get(id=partner_id) + except Partner.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "Partner not found."}, + status=404, + ) + + # Update fields if provided + if data.get("name") is not None: + partner.name = data["name"] + if data.get("partner_type") is not None: + valid_partner_types = [choice[0] for choice in Partner._meta.get_field("partner_type").choices] + if data["partner_type"] not in valid_partner_types: + return JsonResponse( + { + "status": "error", + "message": f"Invalid partner_type. Must be one of: {', '.join(valid_partner_types)}", + }, + status=400, + ) + partner.partner_type = data["partner_type"] + if data.get("primary_contact_person_name") is not None: + partner.primary_contact_person_name = data["primary_contact_person_name"] + if data.get("primary_contact_person_email") is not None: + partner.primary_contact_person_email = data["primary_contact_person_email"] + if data.get("primary_contact_person_phone") is not None: + partner.primary_contact_person_phone = data["primary_contact_person_phone"] + if "address" in data: + partner.address = data["address"] + if "city" in data: + partner.city = data["city"] + if "state" in data: + partner.state = data["state"] + if "country" in data: + partner.country = data["country"] + if "website_url" in data: + partner.website_url = data["website_url"] + if "pincode" in data: + partner.pincode = data["pincode"] + if data.get("latitude") is not None: + try: + partner.latitude = float(data["latitude"]) + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "latitude must be numeric."}, + status=400, + ) + if data.get("longitude") is not None: + try: + partner.longitude = float(data["longitude"]) + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "longitude must be numeric."}, + status=400, + ) + if data.get("is_kyc_compliant") is not None: + partner.is_kyc_compliant = bool(data["is_kyc_compliant"]) + if data.get("kyc_compliance_status") is not None: + valid_statuses = [choice[0] for choice in Partner._meta.get_field("kyc_compliance_status").choices] + if data["kyc_compliance_status"] not in valid_statuses: + return JsonResponse( + { + "status": "error", + "message": f"Invalid kyc_compliance_status. Must be one of: {', '.join(valid_statuses)}", + }, + status=400, + ) + partner.kyc_compliance_status = data["kyc_compliance_status"] + if "kyc_compliance_reason" in data: + partner.kyc_compliance_reason = data["kyc_compliance_reason"] + if "kyc_compliance_document_type" in data: + partner.kyc_compliance_document_type = data["kyc_compliance_document_type"] + if "kyc_compliance_document_other_type" in data: + partner.kyc_compliance_document_other_type = data["kyc_compliance_document_other_type"] + if "kyc_compliance_document_number" in data: + partner.kyc_compliance_document_number = data["kyc_compliance_document_number"] + + # Handle file upload if provided + if "kyc_compliance_document_file" in request.FILES: + partner.kyc_compliance_document_file = request.FILES["kyc_compliance_document_file"] + + partner.save() + log("info", "Partner updated", request=request, user=user, logger_data={"partner_id": partner_id}) + return JsonResponse( + {"status": "success", "partner": _partner_to_dict(partner, request)}, + status=200, + ) + except Exception as e: + log("error", "Partner update exception", request=request, logger_data={"error": str(e)}) + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerDeleteAPI(APIView): + """ + Delete an existing Partner. + Body: token, username, partner_id. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + partner_id = data.get("partner_id") + if not partner_id: + return JsonResponse( + {"status": "error", "message": "partner_id is required."}, + status=400, + ) + + try: + partner = Partner.objects.get(id=partner_id) + except Partner.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "Partner not found."}, + status=404, + ) + + partner_name = partner.name + partner.delete() + log("info", "Partner deleted", request=request, user=user, logger_data={"partner_id": partner_id, "partner_name": partner_name}) + return JsonResponse( + {"status": "success", "message": "Partner deleted successfully."}, + status=200, + ) + except Exception as e: + log("error", "Partner delete exception", request=request, logger_data={"error": str(e)}) + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerUpdateKYCDocumentsAPI(APIView): + """ + Update KYC documents for an existing Partner. + Body: token, username, partner_id (required); + kyc_compliance_document_type, kyc_compliance_document_other_type, + kyc_compliance_document_number (optional); + kyc_compliance_document_file (file upload, optional); + is_kyc_compliant, kyc_compliance_status, kyc_compliance_reason (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + partner_id = data.get("partner_id") + if not partner_id: + return JsonResponse( + {"status": "error", "message": "partner_id is required."}, + status=400, + ) + + try: + partner = Partner.objects.get(id=partner_id) + except Partner.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "Partner not found."}, + status=404, + ) + + # Update KYC document fields + if "kyc_compliance_document_type" in data: + document_type = data["kyc_compliance_document_type"] + if document_type: # Allow empty string to clear + valid_types = [choice[0] for choice in Partner._meta.get_field("kyc_compliance_document_type").choices] + if document_type not in valid_types: + return JsonResponse( + { + "status": "error", + "message": f"Invalid kyc_compliance_document_type. Must be one of: {', '.join(valid_types)}", + }, + status=400, + ) + partner.kyc_compliance_document_type = document_type if document_type else None + + if "kyc_compliance_document_other_type" in data: + partner.kyc_compliance_document_other_type = data["kyc_compliance_document_other_type"] or None + + if "kyc_compliance_document_number" in data: + partner.kyc_compliance_document_number = data["kyc_compliance_document_number"] or None + + # Handle file upload + if "kyc_compliance_document_file" in request.FILES: + partner.kyc_compliance_document_file = request.FILES["kyc_compliance_document_file"] + + # Optionally update compliance status fields + if data.get("is_kyc_compliant") is not None: + partner.is_kyc_compliant = bool(data["is_kyc_compliant"]) + + if data.get("kyc_compliance_status") is not None: + valid_statuses = [choice[0] for choice in Partner._meta.get_field("kyc_compliance_status").choices] + if data["kyc_compliance_status"] not in valid_statuses: + return JsonResponse( + { + "status": "error", + "message": f"Invalid kyc_compliance_status. Must be one of: {', '.join(valid_statuses)}", + }, + status=400, + ) + partner.kyc_compliance_status = data["kyc_compliance_status"] + + if "kyc_compliance_reason" in data: + partner.kyc_compliance_reason = data["kyc_compliance_reason"] or None + + partner.save() + return JsonResponse( + { + "status": "success", + "message": "KYC documents updated successfully.", + "partner": _partner_to_dict(partner, request), + }, + status=200, + ) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerUpdateAddressLocationAPI(APIView): + """ + Update address and location fields for an existing Partner. + Body: token, username, partner_id (required); + address, city, state, country, website_url, pincode, latitude, longitude (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + partner_id = data.get("partner_id") + if not partner_id: + return JsonResponse( + {"status": "error", "message": "partner_id is required."}, + status=400, + ) + + try: + partner = Partner.objects.get(id=partner_id) + except Partner.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "Partner not found."}, + status=404, + ) + + # Update address fields + if "address" in data: + partner.address = data["address"] or None + if "city" in data: + partner.city = data["city"] or None + if "state" in data: + partner.state = data["state"] or None + if "country" in data: + partner.country = data["country"] or None + if "website_url" in data: + partner.website_url = data["website_url"] or None + if "pincode" in data: + partner.pincode = data["pincode"] or None + + # Update location coordinates with validation + if data.get("latitude") is not None: + try: + latitude = float(data["latitude"]) + # Validate latitude range (-90 to 90) + if latitude < -90 or latitude > 90: + return JsonResponse( + {"status": "error", "message": "latitude must be between -90 and 90."}, + status=400, + ) + partner.latitude = latitude + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "latitude must be numeric."}, + status=400, + ) + + if data.get("longitude") is not None: + try: + longitude = float(data["longitude"]) + # Validate longitude range (-180 to 180) + if longitude < -180 or longitude > 180: + return JsonResponse( + {"status": "error", "message": "longitude must be between -180 and 180."}, + status=400, + ) + partner.longitude = longitude + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "longitude must be numeric."}, + status=400, + ) + + partner.save() + return JsonResponse( + { + "status": "success", + "message": "Address and location updated successfully.", + "partner": _partner_to_dict(partner, request), + }, + status=200, + ) + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +def _user_to_dict(user, request=None): + """Serialize User for JSON.""" + data = model_to_dict( + user, + fields=[ + "id", + "username", + "email", + "phone_number", + "role", + "is_staff", + "is_customer", + "is_user", + "pincode", + "district", + "state", + "country", + "place", + "latitude", + "longitude", + ], + ) + # Add profile picture URL if exists + if user.profile_picture: + if request: + data["profile_picture"] = request.build_absolute_uri(user.profile_picture.url) + else: + data["profile_picture"] = user.profile_picture.url + else: + data["profile_picture"] = None + return data + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerCreateUserAPI(APIView): + """ + Create a user with partner-related role (partner, partner_manager, partner_staff, partner_customer). + Body: token, username, email, password, role (required); + phone_number, partner_id, pincode, district, state, country, place, latitude, longitude (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + username = data.get("username") + email = data.get("email") + password = data.get("password") + role = data.get("role") + partner_id = data.get("partner_id") + + if not all([username, email, password, role]): + return JsonResponse( + { + "status": "error", + "message": "username, email, password, and role are required.", + }, + status=400, + ) + + # Validate role - must be one of the partner-related roles + valid_partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"] + if role not in valid_partner_roles: + return JsonResponse( + { + "status": "error", + "message": f"Invalid role. Must be one of: {', '.join(valid_partner_roles)}", + }, + status=400, + ) + + # Check if username already exists + if User.objects.filter(username=username).exists(): + return JsonResponse( + {"status": "error", "message": "Username already exists."}, + status=400, + ) + + # Check if email already exists + if User.objects.filter(email=email).exists(): + return JsonResponse( + {"status": "error", "message": "Email already exists."}, + status=400, + ) + + # Validate partner_id if provided + partner = None + if partner_id: + try: + partner = Partner.objects.get(id=partner_id) + except Partner.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "Partner not found."}, + status=404, + ) + + # Create user + new_user = User.objects.create_user( + username=username, + email=email, + password=password, + role=role, + phone_number=data.get("phone_number"), + pincode=data.get("pincode"), + district=data.get("district"), + state=data.get("state"), + country=data.get("country"), + place=data.get("place"), + ) + + # Set location coordinates if provided + if data.get("latitude") is not None: + try: + latitude = float(data["latitude"]) + if latitude < -90 or latitude > 90: + return JsonResponse( + {"status": "error", "message": "latitude must be between -90 and 90."}, + status=400, + ) + new_user.latitude = latitude + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "latitude must be numeric."}, + status=400, + ) + + if data.get("longitude") is not None: + try: + longitude = float(data["longitude"]) + if longitude < -180 or longitude > 180: + return JsonResponse( + {"status": "error", "message": "longitude must be between -180 and 180."}, + status=400, + ) + new_user.longitude = longitude + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "longitude must be numeric."}, + status=400, + ) + + # Handle profile picture upload if provided + if "profile_picture" in request.FILES: + new_user.profile_picture = request.FILES["profile_picture"] + + new_user.save() + + response_data = { + "status": "success", + "message": f"User created successfully with role: {role}.", + "user": _user_to_dict(new_user, request), + } + + # Include partner info if linked + if partner: + response_data["partner"] = { + "id": partner.id, + "name": partner.name, + "partner_type": partner.partner_type, + } + + log("info", "Partner user created", request=request, user=user, logger_data={"new_user_id": new_user.id, "username": new_user.username, "role": role}) + return JsonResponse(response_data, status=201) + + except Exception as e: + log("error", "Partner create user exception", request=request, logger_data={"error": str(e)}) + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerListUsersAPI(APIView): + """ + List users associated with a partner (users with partner-related roles). + Body: token, username, partner_id (required); + role (optional filter: partner, partner_manager, partner_staff, partner_customer). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + partner_id = data.get("partner_id") + if not partner_id: + return JsonResponse( + {"status": "error", "message": "partner_id is required."}, + status=400, + ) + + # Validate partner exists + try: + partner = Partner.objects.get(id=partner_id) + except Partner.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "Partner not found."}, + status=404, + ) + + # Filter users by partner-related roles + partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"] + qs = User.objects.filter(role__in=partner_roles).order_by("-id") + + # Optional role filter + role_filter = data.get("role") + if role_filter: + if role_filter not in partner_roles: + return JsonResponse( + { + "status": "error", + "message": f"Invalid role filter. Must be one of: {', '.join(partner_roles)}", + }, + status=400, + ) + qs = qs.filter(role=role_filter) + + # Optionally filter by matching contact email or phone (if partner contact info matches user) + # This is a heuristic approach since there's no direct FK relationship + # You can enhance this logic based on your business requirements + match_by_contact = data.get("match_by_contact", False) + if match_by_contact: + qs = qs.filter( + django_models.Q(email=partner.primary_contact_person_email) + | django_models.Q(phone_number=partner.primary_contact_person_phone) + ) + + users = [_user_to_dict(u, request) for u in qs] + + response_data = { + "status": "success", + "partner": { + "id": partner.id, + "name": partner.name, + "partner_type": partner.partner_type, + }, + "users": users, + "total_count": len(users), + } + + return JsonResponse(response_data, status=200) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerUpdateUserAPI(APIView): + """ + Update a partner user (user with partner-related role). + Body: token, username, user_id (required); + email, phone_number, role (must be partner-related), pincode, district, state, + country, place, latitude, longitude, profile_picture (optional). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + user_id = data.get("user_id") + if not user_id: + return JsonResponse( + {"status": "error", "message": "user_id is required."}, + status=400, + ) + + try: + target_user = User.objects.get(id=user_id) + except User.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "User not found."}, + status=404, + ) + + # Validate that the user has a partner-related role + partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"] + if target_user.role not in partner_roles: + return JsonResponse( + { + "status": "error", + "message": "User is not a partner-related user. Only users with partner roles can be updated.", + }, + status=400, + ) + + # Update fields if provided + if data.get("email") is not None: + new_email = data["email"] + # Check if email already exists for another user + if User.objects.filter(email=new_email).exclude(id=user_id).exists(): + return JsonResponse( + {"status": "error", "message": "Email already exists for another user."}, + status=400, + ) + target_user.email = new_email + + if data.get("phone_number") is not None: + target_user.phone_number = data["phone_number"] or None + + if data.get("role") is not None: + new_role = data["role"] + if new_role not in partner_roles: + return JsonResponse( + { + "status": "error", + "message": f"Invalid role. Must be one of: {', '.join(partner_roles)}", + }, + status=400, + ) + target_user.role = new_role + + if "pincode" in data: + target_user.pincode = data["pincode"] or None + if "district" in data: + target_user.district = data["district"] or None + if "state" in data: + target_user.state = data["state"] or None + if "country" in data: + target_user.country = data["country"] or None + if "place" in data: + target_user.place = data["place"] or None + + if data.get("latitude") is not None: + try: + latitude = float(data["latitude"]) + if latitude < -90 or latitude > 90: + return JsonResponse( + {"status": "error", "message": "latitude must be between -90 and 90."}, + status=400, + ) + target_user.latitude = latitude + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "latitude must be numeric."}, + status=400, + ) + + if data.get("longitude") is not None: + try: + longitude = float(data["longitude"]) + if longitude < -180 or longitude > 180: + return JsonResponse( + {"status": "error", "message": "longitude must be between -180 and 180."}, + status=400, + ) + target_user.longitude = longitude + except (TypeError, ValueError): + return JsonResponse( + {"status": "error", "message": "longitude must be numeric."}, + status=400, + ) + + # Handle profile picture upload if provided + if "profile_picture" in request.FILES: + target_user.profile_picture = request.FILES["profile_picture"] + + # Handle password update if provided + if data.get("password"): + target_user.set_password(data["password"]) + + target_user.save() + + log("info", "Partner user updated", request=request, user=user, logger_data={"user_id": user_id}) + return JsonResponse( + { + "status": "success", + "message": "Partner user updated successfully.", + "user": _user_to_dict(target_user, request), + }, + status=200, + ) + + except Exception as e: + log("error", "Partner update user exception", request=request, logger_data={"error": str(e)}) + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerDeleteUserAPI(APIView): + """ + Delete a partner user (user with partner-related role). + Body: token, username, user_id (required). + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + user_id = data.get("user_id") + if not user_id: + return JsonResponse( + {"status": "error", "message": "user_id is required."}, + status=400, + ) + + try: + target_user = User.objects.get(id=user_id) + except User.DoesNotExist: + return JsonResponse( + {"status": "error", "message": "User not found."}, + status=404, + ) + + # Validate that the user has a partner-related role + partner_roles = ["partner", "partner_manager", "partner_staff", "partner_customer"] + if target_user.role not in partner_roles: + return JsonResponse( + { + "status": "error", + "message": "User is not a partner-related user. Only users with partner roles can be deleted.", + }, + status=400, + ) + + # Prevent deleting yourself + if target_user.id == user.id: + return JsonResponse( + {"status": "error", "message": "You cannot delete your own account."}, + status=400, + ) + + username = target_user.username + target_user.delete() + + log("info", "Partner user deleted", request=request, user=user, logger_data={"user_id": user_id, "username": username}) + return JsonResponse( + { + "status": "success", + "message": f"Partner user '{username}' deleted successfully.", + }, + status=200, + ) + + except Exception as e: + log("error", "Partner delete user exception", request=request, logger_data={"error": str(e)}) + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +# ============================================================================ +# Partner List APIs (Filtered by Criteria) +# ============================================================================ + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerListAllAPI(APIView): + """ + List All Partners API. + Body: token, username (required). + Returns: list of all partners. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + partners = Partner.objects.all().order_by("-id") + partners_list = [_partner_to_dict(p, request) for p in partners] + + return JsonResponse({ + "status": "success", + "partners": partners_list, + "total_count": len(partners_list) + }, status=200) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerListKYCCompliantAPI(APIView): + """ + List KYC Compliant Partners API. + Body: token, username (required). + Returns: list of partners where is_kyc_compliant=True and kyc_compliance_status='approved'. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + partners = Partner.objects.filter( + is_kyc_compliant=True, + kyc_compliance_status='approved' + ).order_by("-id") + partners_list = [_partner_to_dict(p, request) for p in partners] + + return JsonResponse({ + "status": "success", + "partners": partners_list, + "total_count": len(partners_list) + }, status=200) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerListKYCPendingAPI(APIView): + """ + List KYC Pending Partners API. + Body: token, username (required). + Returns: list of partners where kyc_compliance_status='pending'. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + partners = Partner.objects.filter( + kyc_compliance_status='pending' + ).order_by("-id") + partners_list = [_partner_to_dict(p, request) for p in partners] + + return JsonResponse({ + "status": "success", + "partners": partners_list, + "total_count": len(partners_list) + }, status=200) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerListHighRiskAPI(APIView): + """ + List High Risk Partners API. + Body: token, username (required). + Returns: list of partners where kyc_compliance_status='high_risk'. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + partners = Partner.objects.filter( + kyc_compliance_status='high_risk' + ).order_by("-id") + partners_list = [_partner_to_dict(p, request) for p in partners] + + return JsonResponse({ + "status": "success", + "partners": partners_list, + "total_count": len(partners_list) + }, status=200) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@method_decorator(csrf_exempt, name="dispatch") +class PartnerListJoinedThisWeekAPI(APIView): + """ + List Partners Joined This Week API. + Body: token, username (required). + Returns: list of partners created in the last 7 days. + + Note: This API uses id-based filtering as a proxy for creation date. + For accurate results, consider adding a created_date field to the Partner model. + """ + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + # Calculate date range for this week + today = timezone.now().date() + week_start = today - timedelta(days=7) + + # Since Partner model doesn't have created_date, we'll use id as a proxy + # Get the highest id to approximate recent partners + # This is a workaround - ideally Partner model should have created_date field + latest_partner = Partner.objects.order_by('-id').first() + + if latest_partner: + # Get partners created in the last week based on id approximation + # This assumes sequential id assignment + # For accurate results, add created_date field to Partner model + partners = Partner.objects.filter( + id__gte=latest_partner.id - 1000 # Approximate filter + ).order_by("-id") + + # Alternative: If you want to add created_date field, use: + # partners = Partner.objects.filter( + # created_date__gte=week_start + # ).order_by("-created_date") + else: + partners = Partner.objects.none() + + partners_list = [_partner_to_dict(p, request) for p in partners] + + return JsonResponse({ + "status": "success", + "partners": partners_list, + "total_count": len(partners_list), + "note": "This uses id-based approximation. Consider adding created_date field for accurate results." + }, status=200) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) diff --git a/partner/apps.py b/partner/apps.py new file mode 100644 index 0000000..521eba4 --- /dev/null +++ b/partner/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PartnerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'partner' diff --git a/partner/migrations/0001_initial.py b/partner/migrations/0001_initial.py new file mode 100644 index 0000000..28edaad --- /dev/null +++ b/partner/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.27 on 2026-03-13 16:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Partner', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=250)), + ('partner_type', models.CharField(choices=[('venue', 'Venue'), ('promoter', 'Promoter'), ('sponsor', 'Sponsor'), ('vendor', 'Vendor'), ('affiliate', 'Affiliate'), ('other', 'Other')], max_length=250)), + ('primary_contact_person_name', models.CharField(max_length=250)), + ('primary_contact_person_email', models.EmailField(max_length=254)), + ('primary_contact_person_phone', models.CharField(max_length=15)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('suspended', 'Suspended'), ('deleted', 'Deleted'), ('archived', 'Archived')], default='active', max_length=250)), + ('address', models.TextField(blank=True, null=True)), + ('city', models.CharField(blank=True, max_length=250, null=True)), + ('state', models.CharField(blank=True, max_length=250, null=True)), + ('country', models.CharField(blank=True, max_length=250, null=True)), + ('website_url', models.URLField(blank=True, null=True)), + ('pincode', models.CharField(blank=True, max_length=10, null=True)), + ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('is_kyc_compliant', models.BooleanField(default=False)), + ('kyc_compliance_status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('high_risk', 'High Risk'), ('low_risk', 'Low Risk'), ('medium_risk', 'Medium Risk')], default='pending', max_length=250)), + ('kyc_compliance_reason', models.TextField(blank=True, null=True)), + ('kyc_compliance_document_type', models.CharField(blank=True, choices=[('aadhaar', 'Aadhaar'), ('pan', 'PAN'), ('driving_license', 'Driving License'), ('voter_id', 'Voter ID'), ('passport', 'Passport'), ('other', 'Other')], max_length=250, null=True)), + ('kyc_compliance_document_other_type', models.CharField(blank=True, max_length=250, null=True)), + ('kyc_compliance_document_file', models.FileField(blank=True, null=True, upload_to='kyc_documents/')), + ('kyc_compliance_document_number', models.CharField(blank=True, max_length=250, null=True)), + ], + ), + ] diff --git a/partner/migrations/__init__.py b/partner/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/partner/models.py b/partner/models.py new file mode 100644 index 0000000..4ba9eae --- /dev/null +++ b/partner/models.py @@ -0,0 +1,69 @@ +from django.db import models + +PARTNER_TYPE_CHOICES = ( + ('venue', 'Venue'), + ('promoter', 'Promoter'), + ('sponsor', 'Sponsor'), + ('vendor', 'Vendor'), + ('affiliate', 'Affiliate'), + ('other', 'Other'), +) + +KYC_DOCUMENT_TYPE_CHOICES = ( + ('aadhaar', 'Aadhaar'), + ('pan', 'PAN'), + ('driving_license', 'Driving License'), + ('voter_id', 'Voter ID'), + ('passport', 'Passport'), + ('other', 'Other'), +) + +KYC_COMPLIANCE_STATUS_CHOICES = ( + ('pending', 'Pending'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ('high_risk', 'High Risk'), + ('low_risk', 'Low Risk'), + ('medium_risk', 'Medium Risk'), +) + +STATUS_CHOICES = ( + ('active', 'Active'), + ('inactive', 'Inactive'), + ('pending', 'Pending'), + ('suspended', 'Suspended'), + ('deleted', 'Deleted'), + ('archived', 'Archived'), +) + + +class Partner(models.Model): + name = models.CharField(max_length=250) + partner_type = models.CharField(max_length=250, choices=PARTNER_TYPE_CHOICES) + + primary_contact_person_name = models.CharField(max_length=250) + primary_contact_person_email = models.EmailField() + primary_contact_person_phone = models.CharField(max_length=15) + + status = models.CharField(max_length=250, choices=STATUS_CHOICES, default='active') + + address = models.TextField(blank=True, null=True) + city = models.CharField(max_length=250, blank=True, null=True) + state = models.CharField(max_length=250, blank=True, null=True) + country = models.CharField(max_length=250, blank=True, null=True) + website_url = models.URLField(blank=True, null=True) + + pincode = models.CharField(max_length=10, blank=True, null=True) + latitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True) + longitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True) + + is_kyc_compliant = models.BooleanField(default=False) + kyc_compliance_status = models.CharField(max_length=250, choices=KYC_COMPLIANCE_STATUS_CHOICES, default='pending') + kyc_compliance_reason = models.TextField(blank=True, null=True) + kyc_compliance_document_type = models.CharField(max_length=250, choices=KYC_DOCUMENT_TYPE_CHOICES, blank=True, null=True) + kyc_compliance_document_other_type = models.CharField(max_length=250, blank=True, null=True) + kyc_compliance_document_file = models.FileField(upload_to='kyc_documents/', blank=True, null=True) + kyc_compliance_document_number = models.CharField(max_length=250, blank=True, null=True) + + def __str__(self): + return self.name diff --git a/partner/tests.py b/partner/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/partner/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/partner/urls.py b/partner/urls.py new file mode 100644 index 0000000..b0092f8 --- /dev/null +++ b/partner/urls.py @@ -0,0 +1,40 @@ +from django.urls import path + +from partner.api import ( + PartnerCreateAPI, + PartnerListAPI, + PartnerUpdateAPI, + PartnerDeleteAPI, + PartnerUpdateKYCDocumentsAPI, + PartnerUpdateAddressLocationAPI, + PartnerCreateUserAPI, + PartnerListUsersAPI, + PartnerUpdateUserAPI, + PartnerDeleteUserAPI, + PartnerListAllAPI, + PartnerListKYCCompliantAPI, + PartnerListKYCPendingAPI, + PartnerListHighRiskAPI, + PartnerListJoinedThisWeekAPI, +) + + +urlpatterns = [ + path("create/", PartnerCreateAPI.as_view(), name="partner_create"), + path("list/", PartnerListAPI.as_view(), name="partner_list"), + path("update/", PartnerUpdateAPI.as_view(), name="partner_update"), + path("delete/", PartnerDeleteAPI.as_view(), name="partner_delete"), + path("update-kyc-documents/", PartnerUpdateKYCDocumentsAPI.as_view(), name="partner_update_kyc_documents"), + path("update-address-location/", PartnerUpdateAddressLocationAPI.as_view(), name="partner_update_address_location"), + path("create-user/", PartnerCreateUserAPI.as_view(), name="partner_create_user"), + path("list-users/", PartnerListUsersAPI.as_view(), name="partner_list_users"), + path("update-user/", PartnerUpdateUserAPI.as_view(), name="partner_update_user"), + path("delete-user/", PartnerDeleteUserAPI.as_view(), name="partner_delete_user"), + + # Partner List APIs (Filtered by Criteria) + path("list-all/", PartnerListAllAPI.as_view(), name="partner_list_all"), + path("list-kyc-compliant/", PartnerListKYCCompliantAPI.as_view(), name="partner_list_kyc_compliant"), + path("list-kyc-pending/", PartnerListKYCPendingAPI.as_view(), name="partner_list_kyc_pending"), + path("list-high-risk/", PartnerListHighRiskAPI.as_view(), name="partner_list_high_risk"), + path("list-joined-this-week/", PartnerListJoinedThisWeekAPI.as_view(), name="partner_list_joined_this_week"), +] diff --git a/partner/views.py b/partner/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/partner/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/templates/partner/base.html b/templates/partner/base.html new file mode 100644 index 0000000..0622405 --- /dev/null +++ b/templates/partner/base.html @@ -0,0 +1,56 @@ + + + + + + Eventify Partner Portal + + + + +
+ {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% block content %}{% endblock %} +
+ + + diff --git a/templates/partner/dashboard.html b/templates/partner/dashboard.html new file mode 100644 index 0000000..b5e4a67 --- /dev/null +++ b/templates/partner/dashboard.html @@ -0,0 +1,12 @@ +{% extends 'partner/base.html' %} +{% block content %} +
+
+
+
Total Partner Users
+

{{ total_partner_users }}

+
+
+ +
+{% endblock %} diff --git a/templates/partner/login.html b/templates/partner/login.html new file mode 100644 index 0000000..631eace --- /dev/null +++ b/templates/partner/login.html @@ -0,0 +1,56 @@ +{% load static %} + + + + Partner Login + + + + + +
+
+ +
+ +

Eventify Partner Portal

+ +

Login

+ + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + +
+ {% csrf_token %} + +
+ {{ form.username.label_tag }} + {{ form.username }} + {% for error in form.username.errors %} + {{ error }} + {% endfor %} +
+ +
+ {{ form.password.label_tag }} + {{ form.password }} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+ + +
+ +
+ +
+
+ + + diff --git a/templates/partner/user_confirm_delete.html b/templates/partner/user_confirm_delete.html new file mode 100644 index 0000000..4b1df9a --- /dev/null +++ b/templates/partner/user_confirm_delete.html @@ -0,0 +1,7 @@ +{% extends 'partner/base.html' %} +{% block content %} +

Delete Partner User

+

Are you sure you want to delete {{ object.username }}?

+
{% csrf_token %} +Cancel
+{% endblock %} diff --git a/templates/partner/user_form.html b/templates/partner/user_form.html new file mode 100644 index 0000000..3d96e21 --- /dev/null +++ b/templates/partner/user_form.html @@ -0,0 +1,26 @@ +{% extends 'partner/base.html' %} +{% block content %} +
+

{% if object %}Edit{% else %}Add{% endif %} Partner User

+ +
+ {% csrf_token %} + + {% for field in form %} +
+ {{ field.label_tag }} + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} + + + Cancel +
+
+{% endblock %} diff --git a/templates/partner/user_list.html b/templates/partner/user_list.html new file mode 100644 index 0000000..0588887 --- /dev/null +++ b/templates/partner/user_list.html @@ -0,0 +1,27 @@ +{% extends 'partner/base.html' %} +{% block content %} +
+

Partner Users

+ Add User +
+ + + + {% for u in users %} + + + + + + + + + {% empty %} + + {% endfor %} + +
#UsernameEmailPhoneRoleActions
{{ forloop.counter }}{{ u.username }}{{ u.email }}{{ u.phone_number }}{{ u.get_role_display }} + Edit + Delete +
No users yet.
+{% endblock %} diff --git a/urls_text.txt b/urls_text.txt new file mode 100644 index 0000000..c6409d9 --- /dev/null +++ b/urls_text.txt @@ -0,0 +1,98 @@ +/admin/ +/ +/register/ +/logout/ +/dashboard/ +/users/ +/users/add/ +/users//edit/ +/users//delete/ + +/master-data/event-types/ +/master-data/event-types/add/ +/master-data/event-types//edit/ +/master-data/event-types//delete/ + +/events/ +/events/add/ +/events//edit/ +/events//delete/ +/events//images/ +/events//images/add/ +/events//images//delete/ +/events//images//primary/ +/events/api/create/ + +/accounts/login/ +/accounts/logout/ +/accounts/dashboard/ +/accounts/users/ +/accounts/accounts/users/add/ +/accounts/users//edit/ +/accounts/users//delete/ +/accounts/api/login/ +/accounts/api/logout/ +/accounts/api/users/list/ +/accounts/api/users/create/ +/accounts/api/users/update/ +/accounts/api/users/delete/ +/accounts/api/partner/login/ +/accounts/api/partner/logout/ +/accounts/api/partner/dashboard/ +/accounts/api/partner/users/list/ +/accounts/api/partner/users/create/ +/accounts/api/partner/users/update/ +/accounts/api/partner/users/delete/ + +/bookings/ticket-meta/create/ +/bookings/ticket-meta/list/ +/bookings/ticket-meta/update/ +/bookings/ticket-meta/delete/ +/bookings/ticket-meta/deactivate/ +/bookings/ticket-type/create/ +/bookings/ticket-type/list/ +/bookings/ticket-type/update/ +/bookings/ticket-type/delete/ +/bookings/ticket-type/deactivate/ +/bookings/cart/add/ +/bookings/cart/delete/ +/bookings/checkout/ +/bookings/check-in/ + +/partner/create/ +/partner/list/ +/partner/update/ +/partner/delete/ +/partner/update-kyc-documents/ +/partner/update-address-location/ +/partner/create-user/ +/partner/list-users/ +/partner/update-user/ +/partner/delete-user/ +/partner/list-all/ +/partner/list-kyc-compliant/ +/partner/list-kyc-pending/ +/partner/list-high-risk/ +/partner/list-joined-this-week/ + +/banking/payment-gateway/create/ +/banking/payment-gateway/list/ +/banking/payment-gateway/update/ +/banking/payment-gateway/delete/ +/banking/payment-gateway-credentials/create/ +/banking/payment-gateway-credentials/list/ +/banking/payment-gateway-credentials/update/ +/banking/payment-gateway-credentials/delete/ + +/api/user/register/ +/api/user/login/ +/api/user/status/ +/api/user/logout/ +/api/user/update-profile/ +/api/events/type-list/ +/api/events/pincode-events/ +/api/events/event-details/ +/api/events/event-images/ +/api/events/events-by-category/ +/api/events/events-by-month-year/ +/api/events/events-by-date/