diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..b952bc27a1fe269f2f3c110bd69e2b84b9a7e3c2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.10-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +WORKDIR /app/kdon + +EXPOSE 7860 + +CMD ["gunicorn", "basho_backend.basho_backend.wsgi:application", "--bind", "0.0.0.0:7860"] diff --git a/basho_backend/apps/__init__.py b/basho_backend/apps/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/accounts/__init__.py b/basho_backend/apps/accounts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/accounts/admin.py b/basho_backend/apps/accounts/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..94fce99ff3f7b5ebc269ea84a6a2d51026546d0b --- /dev/null +++ b/basho_backend/apps/accounts/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin +from .models import User, EmailOTP + +admin.site.register(User) +admin.site.register(EmailOTP) diff --git a/basho_backend/apps/accounts/apps.py b/basho_backend/apps/accounts/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..7612b7e0f4807abe7b7c62974e61b5f65c833ca4 --- /dev/null +++ b/basho_backend/apps/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.accounts" + label = "accounts" diff --git a/basho_backend/apps/accounts/managers.py b/basho_backend/apps/accounts/managers.py new file mode 100644 index 0000000000000000000000000000000000000000..c127ed84eaed06a7b9edd449cd7990c9d985fd8d --- /dev/null +++ b/basho_backend/apps/accounts/managers.py @@ -0,0 +1,21 @@ +from django.contrib.auth.models import BaseUserManager + +class UserManager(BaseUserManager): + def create_user(self, username, email, password=None): + if not email: + raise ValueError("Email is required") + + user = self.model( + username=username, + email=self.normalize_email(email), + ) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, username, email, password): + user = self.create_user(username, email, password) + user.is_staff = True + user.is_superuser = True + user.save(using=self._db) + return user diff --git a/basho_backend/apps/accounts/migrations/0001_initial.py b/basho_backend/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..bf5c32e7d533458d7069cff7b254771cfcb9fe90 --- /dev/null +++ b/basho_backend/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0 on 2026-01-08 12:43 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='EmailOTP', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254)), + ('otp', models.CharField(max_length=6)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(max_length=150, unique=True)), + ('email', models.EmailField(max_length=254, unique=True)), + ('is_email_verified', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ('is_staff', models.BooleanField(default=False)), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/basho_backend/apps/accounts/migrations/0002_user_profile_image.py b/basho_backend/apps/accounts/migrations/0002_user_profile_image.py new file mode 100644 index 0000000000000000000000000000000000000000..d119672ddcd2fa21d1582ed721caf2230ee39d46 --- /dev/null +++ b/basho_backend/apps/accounts/migrations/0002_user_profile_image.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-01-10 13:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='profile_image', + field=models.ImageField(blank=True, null=True, upload_to='profile_pics/'), + ), + ] diff --git a/basho_backend/apps/accounts/migrations/0003_alter_user_profile_image.py b/basho_backend/apps/accounts/migrations/0003_alter_user_profile_image.py new file mode 100644 index 0000000000000000000000000000000000000000..c844b99f15a97daaf712763f403292e2a736f14a --- /dev/null +++ b/basho_backend/apps/accounts/migrations/0003_alter_user_profile_image.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-01-12 19:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_user_profile_image'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='profile_image', + field=models.ImageField(blank=True, default='profile_pics/default/default.png', upload_to='profile_pics/'), + ), + ] diff --git a/basho_backend/apps/accounts/migrations/0004_remove_user_profile_image_user_avatar.py b/basho_backend/apps/accounts/migrations/0004_remove_user_profile_image_user_avatar.py new file mode 100644 index 0000000000000000000000000000000000000000..27dfaa73d385055a91864496225fc92fa310343c --- /dev/null +++ b/basho_backend/apps/accounts/migrations/0004_remove_user_profile_image_user_avatar.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0 on 2026-01-15 15:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_user_profile_image'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='profile_image', + ), + migrations.AddField( + model_name='user', + name='avatar', + field=models.CharField(default='p1.png', max_length=50), + ), + ] diff --git a/basho_backend/apps/accounts/migrations/__init__.py b/basho_backend/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/accounts/models.py b/basho_backend/apps/accounts/models.py new file mode 100644 index 0000000000000000000000000000000000000000..3b7b93e61d199891819a19c99420c4e0aa39c0ab --- /dev/null +++ b/basho_backend/apps/accounts/models.py @@ -0,0 +1,39 @@ +from django.db import models +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.utils import timezone +from .managers import UserManager + + +class User(AbstractBaseUser, PermissionsMixin): + username = models.CharField(max_length=150, unique=True) + email = models.EmailField(unique=True) + + avatar = models.CharField( + max_length=50, + default="p1.png" +) + + + + is_email_verified = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + + date_joined = models.DateTimeField(default=timezone.now) + + objects = UserManager() + + USERNAME_FIELD = "username" + REQUIRED_FIELDS = ["email"] + + def __str__(self): + return self.username + + +class EmailOTP(models.Model): + email = models.EmailField() + otp = models.CharField(max_length=6) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.email} - {self.otp}" diff --git a/basho_backend/apps/accounts/otp.py b/basho_backend/apps/accounts/otp.py new file mode 100644 index 0000000000000000000000000000000000000000..0e3ad8170a5cb9bfc42076e11364ea65132805eb --- /dev/null +++ b/basho_backend/apps/accounts/otp.py @@ -0,0 +1,4 @@ +import random + +def generate_otp(): + return str(random.randint(100000, 999999)) diff --git a/basho_backend/apps/accounts/services.py b/basho_backend/apps/accounts/services.py new file mode 100644 index 0000000000000000000000000000000000000000..9be332f489d47301b027cd75d0efe87d87ed630a --- /dev/null +++ b/basho_backend/apps/accounts/services.py @@ -0,0 +1,107 @@ +from django.core.mail import EmailMultiAlternatives +from django.conf import settings + +def send_otp_email(email, otp): + subject = "Welcome to Basho byy Shivangi" + + text_content = f""" +Welcome to Basho byy Shivangi! + +We’re delighted to have you join us. + +Your One-Time Password (OTP) is: {otp} + +⏳This OTP is valid for 5 minutes. +Do not share this OTP with anyone. + +Warm regards, +Team Basho byy Shivangi +""" + + html_content = f""" + + +

Welcome to Basho byy Shivangi!

+ +

We’re delighted to have you join us.

+ +

Your One-Time Password (OTP) is:

+ +

+ {otp} +

+ +

This OTP is valid for 5 minutes.

+

Please do not share this OTP with anyone.

+ +
+

Warm regards,
Team Basho byy Shivangi

+ + + """ + + email_message = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[email], + ) + + email_message.attach_alternative(html_content, "text/html") + email_message.send() + + +def send_welcome_email(email, username): + subject = "Welcome to Basho byy Shivangi " + + text_content = f""" +Welcome to Basho byy Shivangi, {username}! + +Your account has been successfully created. + +We’re delighted to have you as part of our community. + +Warm regards, +Team Basho byy Shivangi +""" + + html_content = f""" + + +

Welcome to Basho byy Shivangi

+ +

Hi {username},

+ +

+ Your account has been successfully created. + We’re delighted to have you join our journey of handcrafted elegance. +

+ +

+ You can now explore our collections, workshops, and custom creations. +

+ +
+ +

+ Warm regards,
Team Basho byy Shivangi! +

+ + + """ + + email_message = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[email], + ) + + email_message.attach_alternative(html_content, "text/html") + email_message.send() diff --git a/basho_backend/apps/accounts/tests.py b/basho_backend/apps/accounts/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/basho_backend/apps/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/basho_backend/apps/accounts/urls.py b/basho_backend/apps/accounts/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..33138dee3aa051dde1e0ae7e454dd49a5eac4be9 --- /dev/null +++ b/basho_backend/apps/accounts/urls.py @@ -0,0 +1,38 @@ +from django.urls import path +from .views import ( + send_otp, + register_user, + login_user, + google_login, + google_register, + change_username, + me, + set_avatar, + +) + +urlpatterns = [ + path("send-otp/", send_otp), + path("register/", register_user), + path("login/", login_user), + path("google-login/", google_login), + path("google-register/", google_register), + path("change-username/", change_username), + path("me/", me), + path("set-avatar/", set_avatar), +] + +from .views import ( + forgot_password_send_otp, + forgot_password_verify_otp, + forgot_password_reset, + me +) + +urlpatterns += [ + path("forgot-password/send-otp/", forgot_password_send_otp), + path("forgot-password/verify-otp/", forgot_password_verify_otp), + path("forgot-password/reset-password/", forgot_password_reset), + path("me/", me), + +] diff --git a/basho_backend/apps/accounts/views.py b/basho_backend/apps/accounts/views.py new file mode 100644 index 0000000000000000000000000000000000000000..c743a9c475e5544489ddcd08cf9394189dcbd675 --- /dev/null +++ b/basho_backend/apps/accounts/views.py @@ -0,0 +1,304 @@ +from rest_framework.decorators import api_view, permission_classes, authentication_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.contrib.auth import authenticate +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.authentication import JWTAuthentication +from django.utils import timezone +from datetime import timedelta +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth import get_user_model +from django.utils.crypto import get_random_string +import re + +from .models import User, EmailOTP +from .otp import generate_otp +from .services import send_otp_email, send_welcome_email + + +import os +from django.core.files import File +from django.conf import settings +from django.views.decorators.csrf import csrf_exempt + + + +# ---------------- HELPERS ---------------- + +def is_strong_password(password): + if len(password) < 8: + return False + if not re.search(r"[A-Z]", password): + return False + if not re.search(r"[^A-Za-z0-9]", password): + return False + return True + + +# ---------------- AUTH ---------------- + +@api_view(["POST"]) +def send_otp(request): + email = request.data.get("email") + username = request.data.get("username") + + if not email or not username: + return Response({"error": "Email and username required"}, status=400) + + if User.objects.filter(username=username).exists(): + return Response({"error": "Username already exists"}, status=400) + + if User.objects.filter(email=email).exists(): + return Response({"error": "Email already registered"}, status=400) + + otp = generate_otp() + + EmailOTP.objects.update_or_create( + email=email, + defaults={"otp": otp, "created_at": timezone.now()} + ) + + send_otp_email(email, otp) + return Response({"success": "OTP sent"}) + + +@api_view(["POST"]) +def register_user(request): + email = request.data.get("email") + username = request.data.get("username") + password = request.data.get("password") + otp = request.data.get("otp") + + if not all([email, username, password, otp]): + return Response({"error": "All fields are required"}, status=400) + + otp_obj = EmailOTP.objects.filter(email=email, otp=otp).first() + if not otp_obj: + return Response({"error": "Incorrect OTP"}, status=400) + + if timezone.now() - otp_obj.created_at > timedelta(minutes=5): + otp_obj.delete() + return Response({"error": "OTP expired"}, status=400) + + if not is_strong_password(password): + return Response( + {"error": "Password must be at least 8 characters, include 1 uppercase letter and 1 special character"}, + status=400 + ) + + user = User.objects.create_user( + username=username, + email=email, + password=password + ) + user.is_email_verified = True + user.save() + + send_welcome_email(email, username) + otp_obj.delete() + + refresh = RefreshToken.for_user(user) + + return Response({ + "access": str(refresh.access_token), + "refresh": str(refresh), + "username": user.username, + }) + + +@api_view(["POST"]) +def login_user(request): + user = authenticate( + username=request.data.get("username"), + password=request.data.get("password") + ) + + if not user: + return Response({"error": "Incorrect username or password"}, status=400) + + refresh = RefreshToken.for_user(user) + + return Response({ + "access": str(refresh.access_token), + "refresh": str(refresh), + "username": user.username, + "email": user.email, + "avatar": user.avatar, + + }) + + +@api_view(["POST"]) +def google_login(request): + email = request.data.get("email") + user = User.objects.filter(email=email).first() + + if not user: + return Response({"error": "Account not found. Please register first."}, status=400) + + refresh = RefreshToken.for_user(user) + + return Response({ + "access": str(refresh.access_token), + "refresh": str(refresh), + "username": user.username, + "email": user.email, + "avatar": user.avatar, + + }) + + +@api_view(["POST"]) +def google_register(request): + email = request.data.get("email") + username = request.data.get("username") + + password = get_random_string(12) + + user = User.objects.create_user( + username=username, + email=email, + password=password + ) + user.is_email_verified = True + user.save() + + refresh = RefreshToken.for_user(user) + + return Response({ + "access": str(refresh.access_token), + "refresh": str(refresh), + "username": user.username, + }) + + +# ---------------- CHANGE USERNAME (UNCHANGED LOGIC) ---------------- + +@csrf_exempt +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def change_username(request): + user = request.user + new_username = request.data.get("username", "").strip() + + if not new_username: + return Response({"error": "Username cannot be empty"}, status=400) + + if User.objects.filter(username=new_username).exclude(id=user.id).exists(): + return Response({"error": "Username already taken"}, status=400) + + user.username = new_username + user.save(update_fields=["username"]) + + return Response({"username": user.username}) + + +# ---------------- PROFILE PICTURE ---------------- + +@api_view(["POST"]) +@authentication_classes([JWTAuthentication]) +@permission_classes([IsAuthenticated]) +def set_avatar(request): + user = request.user + avatar = request.data.get("avatar") + + if not avatar: + return Response({"error": "Avatar is required"}, status=400) + + # optional safety check + allowed_avatars = [ + "p1.png", "p2.png", "p3.png", "p4.png", "p5.png", + "p6.png", "p7.png", "p8.png", "p9.png", "p10.png", + "p11.png", "p12.png", "p13.png", "p14.png", + "p15.png", "p16.png", "p17.png", + ] + + if avatar not in allowed_avatars: + return Response({"error": "Invalid avatar"}, status=400) + + user.avatar = avatar + user.save(update_fields=["avatar"]) + + return Response({ + "avatar": user.avatar + }) + + +@api_view(["GET"]) +@authentication_classes([JWTAuthentication]) +@permission_classes([IsAuthenticated]) +def me(request): + user = request.user + return Response({ + "username": user.username, + "email": user.email, + "avatar": user.avatar, + + }) + + + +# ---------------- FORGOT PASSWORD ---------------- + +@api_view(["POST"]) +def forgot_password_send_otp(request): + email = request.data.get("email") + user = User.objects.filter(email=email).first() + + if not user: + return Response({"error": "Incorrect email id entered"}, status=400) + + otp = generate_otp() + EmailOTP.objects.update_or_create( + email=email, + defaults={"otp": otp, "created_at": timezone.now()} + ) + + send_otp_email(email, otp) + return Response({"success": "OTP sent"}) + + +@api_view(["POST"]) +def forgot_password_verify_otp(request): + email = request.data.get("email") + otp = request.data.get("otp") + + otp_obj = EmailOTP.objects.filter(email=email, otp=otp).first() + if not otp_obj: + return Response({"error": "Incorrect OTP entered"}, status=400) + + if timezone.now() - otp_obj.created_at > timedelta(minutes=5): + otp_obj.delete() + return Response({"error": "OTP expired"}, status=400) + + return Response({"success": "OTP verified"}) + + +@api_view(["POST"]) +def forgot_password_reset(request): + email = request.data.get("email") + new_password = request.data.get("new_password") + confirm_password = request.data.get("confirm_password") + + if new_password != confirm_password: + return Response({"error": "Passwords do not match"}, status=400) + + if not is_strong_password(new_password): + return Response({"error": "Password must be strong"}, status=400) + + user = User.objects.filter(email=email).first() + user.set_password(new_password) + user.save() + + EmailOTP.objects.filter(email=email).delete() + return Response({"success": "Password reset successful"}) + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def me(request): + user = request.user + return Response({ + "id": user.id, + "username": user.username, + "email": user.email, + }) \ No newline at end of file diff --git a/basho_backend/apps/corporate/__init__.py b/basho_backend/apps/corporate/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/corporate/admin.py b/basho_backend/apps/corporate/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..de18d5f5e37278b2a340abf333004a9b15f14ba0 --- /dev/null +++ b/basho_backend/apps/corporate/admin.py @@ -0,0 +1,49 @@ +from django.contrib import admin +from .models import CorporateInquiry + + +@admin.register(CorporateInquiry) +class CorporateInquiryAdmin(admin.ModelAdmin): + """ + Read-only Admin for Corporate Inquiries + """ + + # ✅ Show fields in list view + list_display = ( + "company_name", + "contact_name", + "email", + "service_type", + "created_at", + ) + + # ✅ Allow searching + search_fields = ( + "company_name", + "contact_name", + "email", + ) + + # ✅ Filters on right sidebar + list_filter = ( + "service_type", + "created_at", + ) + + # 🔒 Make ALL fields read-only + readonly_fields = [field.name for field in CorporateInquiry._meta.fields] + + # ❌ Disable ADD permission + def has_add_permission(self, request): + return False + + # ❌ Disable DELETE permission + def has_delete_permission(self, request, obj=None): + return False + + # ❌ Disable EDIT (save) permission + def has_change_permission(self, request, obj=None): + # Allow viewing but not editing + if request.method in ["GET", "HEAD"]: + return True + return False diff --git a/basho_backend/apps/corporate/apps.py b/basho_backend/apps/corporate/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..b5f0325d071d7baece6bfa5574a692f1eaccdd5d --- /dev/null +++ b/basho_backend/apps/corporate/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class CorporateConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.corporate" diff --git a/basho_backend/apps/corporate/migrations/0001_initial.py b/basho_backend/apps/corporate/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..5b9c2ec6cc2c435356ba4ffedf3f4c9fef4d4feb --- /dev/null +++ b/basho_backend/apps/corporate/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0 on 2026-01-09 19:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CorporateInquiry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('company_name', models.CharField(max_length=255)), + ('company_website', models.URLField(blank=True)), + ('contact_name', models.CharField(max_length=255)), + ('email', models.EmailField(max_length=254)), + ('phone', models.CharField(blank=True, max_length=20)), + ('service_type', models.CharField(choices=[('Corporate Gifting', 'Corporate Gifting'), ('Team Workshop', 'Team Workshop'), ('Brand Collaboration', 'Brand Collaboration')], max_length=50)), + ('details', models.JSONField(blank=True, default=dict)), + ('message', models.TextField(blank=True)), + ('budget', models.CharField(blank=True, max_length=100)), + ('timeline', models.CharField(blank=True, max_length=100)), + ('consent', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/basho_backend/apps/corporate/migrations/0002_alter_corporateinquiry_options.py b/basho_backend/apps/corporate/migrations/0002_alter_corporateinquiry_options.py new file mode 100644 index 0000000000000000000000000000000000000000..28bcce7ef29f313a218cfe122de0838042493a46 --- /dev/null +++ b/basho_backend/apps/corporate/migrations/0002_alter_corporateinquiry_options.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0 on 2026-01-12 15:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('corporate', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='corporateinquiry', + options={'ordering': ['-created_at']}, + ), + ] diff --git a/basho_backend/apps/corporate/migrations/__init__.py b/basho_backend/apps/corporate/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/corporate/models.py b/basho_backend/apps/corporate/models.py new file mode 100644 index 0000000000000000000000000000000000000000..2499b7ae8bad8391395cfc1eb3d9354b2a331dc1 --- /dev/null +++ b/basho_backend/apps/corporate/models.py @@ -0,0 +1,39 @@ +from django.db import models + + +class CorporateInquiry(models.Model): + SERVICE_CHOICES = [ + ("Corporate Gifting", "Corporate Gifting"), + ("Team Workshop", "Team Workshop"), + ("Brand Collaboration", "Brand Collaboration"), + ] + + company_name = models.CharField(max_length=255) + company_website = models.URLField(blank=True) + + contact_name = models.CharField(max_length=255) + email = models.EmailField() + phone = models.CharField(max_length=20, blank=True) + + service_type = models.CharField( + max_length=50, + choices=SERVICE_CHOICES + ) + + # dynamic form data stored safely + details = models.JSONField(default=dict, blank=True) + + message = models.TextField(blank=True) + budget = models.CharField(max_length=100, blank=True) + timeline = models.CharField(max_length=100, blank=True) + + consent = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.company_name} | {self.service_type}" + + class Meta: + ordering = ["-created_at"] + diff --git a/basho_backend/apps/corporate/tests.py b/basho_backend/apps/corporate/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/basho_backend/apps/corporate/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/basho_backend/apps/corporate/urls.py b/basho_backend/apps/corporate/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..b96cd435dd9d786861486d070ae3a897f85884a2 --- /dev/null +++ b/basho_backend/apps/corporate/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from .views import corporate_inquiry + +urlpatterns = [ + path("corporate-inquiry/", corporate_inquiry), +] diff --git a/basho_backend/apps/corporate/views.py b/basho_backend/apps/corporate/views.py new file mode 100644 index 0000000000000000000000000000000000000000..368640f4d14cb255329081e14cfb91e9b5eee8e4 --- /dev/null +++ b/basho_backend/apps/corporate/views.py @@ -0,0 +1,66 @@ +from rest_framework.decorators import api_view +from rest_framework.response import Response +from rest_framework import status + +from .models import CorporateInquiry + + +@api_view(["POST"]) +def corporate_inquiry(request): + data = request.data + + # 🔒 REQUIRED FIELDS CHECK + required_fields = [ + "companyName", + "contactName", + "email", + "serviceType", + "consent", + ] + + for field in required_fields: + if not data.get(field): + return Response( + {"error": f"{field} is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # ✅ Extract dynamic service-specific fields safely + details = data.copy() + + for key in [ + "companyName", + "companyWebsite", + "contactName", + "email", + "phone", + "serviceType", + "message", + "budget", + "timeline", + "consent", + ]: + details.pop(key, None) + + inquiry = CorporateInquiry.objects.create( + company_name=data["companyName"], + company_website=data.get("companyWebsite", ""), + contact_name=data["contactName"], + email=data["email"], + phone=data.get("phone", ""), + service_type=data["serviceType"], + details=details, + message=data.get("message", ""), + budget=data.get("budget", ""), + timeline=data.get("timeline", ""), + consent=bool(data.get("consent")), + ) + + return Response( + { + "success": True, + "id": inquiry.id, + "message": "Inquiry submitted successfully", + }, + status=status.HTTP_201_CREATED, + ) diff --git a/basho_backend/apps/experiences/__init__.py b/basho_backend/apps/experiences/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/experiences/admin.py b/basho_backend/apps/experiences/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..3367807e6eca4a98560e5769bb03b44102a2db73 --- /dev/null +++ b/basho_backend/apps/experiences/admin.py @@ -0,0 +1,116 @@ +from django.contrib import admin +from .models import ( + Experience, + Booking, + StudioBooking, + UpcomingEvent, + Workshop, + WorkshopSlot, + WorkshopRegistration, + ExperienceSlot, +) + +# ---------- BASIC MODELS ---------- +@admin.register(Experience) +class ExperienceAdmin(admin.ModelAdmin): + list_display = ("title", "price", "is_active") + list_filter = ("is_active",) + search_fields = ("title",) + +@admin.register(ExperienceSlot) +class ExperienceSlotAdmin(admin.ModelAdmin): + list_display = ( + "experience", + "date", + "start_time", + "end_time", + "total_slots", + "booked_slots", + "is_active", + ) + + list_filter = ("experience", "date", "is_active") + search_fields = ("experience__title",) + ordering = ("date", "start_time") + + readonly_fields = ("booked_slots",) + + +@admin.register(Booking) +class BookingAdmin(admin.ModelAdmin): + list_display = ( + "id", + "full_name", + "experience", + "status", + "payment_status_display", + "created_at", + ) + + list_filter = ("status",) + + def payment_status_display(self, obj): + if obj.payment_order: + return obj.payment_order.status + return "NO PAYMENT" + + payment_status_display.short_description = "Payment Status" + + +@admin.register(StudioBooking) +class StudioBookingAdmin(admin.ModelAdmin): + list_display = ("full_name", "email", "phone", "visit_date", "time_slot", "created_at") + search_fields = ("full_name", "email", "phone") + + +@admin.register(UpcomingEvent) +class UpcomingEventAdmin(admin.ModelAdmin): + list_display = ("title", "date", "location") + search_fields = ("title",) + + +# ---------- WORKSHOPS ---------- +@admin.register(Workshop) +class WorkshopAdmin(admin.ModelAdmin): + list_display = ( + "name", + "type", + "level", + "price", + "featured", + "is_active", + ) + list_filter = ("type", "level", "featured", "is_active") + search_fields = ("name", "description", "instructor") + + +@admin.register(WorkshopSlot) +class WorkshopSlotAdmin(admin.ModelAdmin): + list_display = ( + "workshop", + "date", + "start_time", + "end_time", + "available_spots", + "is_available", + ) + list_filter = ("date", "is_available") + + +@admin.register(WorkshopRegistration) +class WorkshopRegistrationAdmin(admin.ModelAdmin): + list_display = ( + "id", + "name", + "workshop", + "status", + "payment_status_display", + "created_at", + ) + + def payment_status_display(self, obj): + if obj.payment_order: + return obj.payment_order.status + return "NO PAYMENT" + + payment_status_display.short_description = "Payment Status" diff --git a/basho_backend/apps/experiences/apps.py b/basho_backend/apps/experiences/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..4224f95555d64273ba13162a3b899ee9ab1c6e2b --- /dev/null +++ b/basho_backend/apps/experiences/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ExperiencesConfig(AppConfig): + name = "apps.experiences" diff --git a/basho_backend/apps/experiences/migrations/0001_initial.py b/basho_backend/apps/experiences/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..02000f719b0b8039419064eb41104f23dd0ac081 --- /dev/null +++ b/basho_backend/apps/experiences/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 6.0 on 2026-01-07 15:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Experience', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('tagline', models.CharField(max_length=200)), + ('description', models.TextField()), + ('duration', models.CharField(max_length=50)), + ('people', models.CharField(max_length=50)), + ('price', models.IntegerField()), + ('image', models.ImageField(upload_to='experiences/')), + ('is_active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='Booking', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=100)), + ('phone', models.CharField(max_length=15)), + ('email', models.EmailField(max_length=254)), + ('booking_date', models.DateField()), + ('number_of_people', models.IntegerField(default=2)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('experience', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookings', to='experiences.experience')), + ], + ), + ] diff --git a/basho_backend/apps/experiences/migrations/0002_booking_payment_amount_booking_payment_status.py b/basho_backend/apps/experiences/migrations/0002_booking_payment_amount_booking_payment_status.py new file mode 100644 index 0000000000000000000000000000000000000000..70309774559428516d5d7cc3dce5f5f4d37ce52e --- /dev/null +++ b/basho_backend/apps/experiences/migrations/0002_booking_payment_amount_booking_payment_status.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0 on 2026-01-07 21:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiences', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='booking', + name='payment_amount', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='booking', + name='payment_status', + field=models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('failed', 'Failed')], default='pending', max_length=20), + ), + ] diff --git a/basho_backend/apps/experiences/migrations/0003_studiobooking_upcomingevent.py b/basho_backend/apps/experiences/migrations/0003_studiobooking_upcomingevent.py new file mode 100644 index 0000000000000000000000000000000000000000..e1cd5cf36ea0095fc1837ea308671c3fc77fcbee --- /dev/null +++ b/basho_backend/apps/experiences/migrations/0003_studiobooking_upcomingevent.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0 on 2026-01-07 21:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiences', '0002_booking_payment_amount_booking_payment_status'), + ] + + operations = [ + migrations.CreateModel( + name='StudioBooking', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=100)), + ('phone', models.CharField(max_length=15)), + ('email', models.EmailField(max_length=254)), + ('visit_date', models.DateField()), + ('time_slot', models.CharField(max_length=50)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='UpcomingEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('date', models.CharField(max_length=50)), + ('location', models.CharField(max_length=200)), + ('description', models.TextField()), + ('badge', models.CharField(default='✨ Upcoming', max_length=50)), + ], + ), + ] diff --git a/basho_backend/apps/experiences/migrations/0004_workshop_workshopslot_workshopregistration.py b/basho_backend/apps/experiences/migrations/0004_workshop_workshopslot_workshopregistration.py new file mode 100644 index 0000000000000000000000000000000000000000..9679b271211bccd22082273db1d2aff9fd264a77 --- /dev/null +++ b/basho_backend/apps/experiences/migrations/0004_workshop_workshopslot_workshopregistration.py @@ -0,0 +1,69 @@ +# Generated by Django 6.0 on 2026-01-08 18:20 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiences', '0003_studiobooking_upcomingevent'), + ] + + operations = [ + migrations.CreateModel( + name='Workshop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('type', models.CharField(choices=[('group', 'Group'), ('private', 'Private'), ('experience', 'Experience')], max_length=20)), + ('level', models.CharField(choices=[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced')], max_length=20)), + ('experience_type', models.CharField(blank=True, choices=[('couples_date', 'Couple’s Date'), ('birthday_party', 'Birthday Party'), ('corporate', 'Corporate'), ('masterclass', 'Masterclass')], max_length=30, null=True)), + ('description', models.TextField()), + ('long_description', models.TextField()), + ('images', models.JSONField(default=list)), + ('duration', models.CharField(max_length=50)), + ('min_participants', models.PositiveIntegerField()), + ('max_participants', models.PositiveIntegerField()), + ('price', models.PositiveIntegerField()), + ('price_per_person', models.BooleanField(default=True)), + ('includes', models.JSONField(default=list)), + ('requirements', models.JSONField(blank=True, null=True)), + ('provided_materials', models.JSONField(default=list)), + ('location', models.CharField(max_length=200)), + ('instructor', models.CharField(max_length=100)), + ('take_home', models.CharField(max_length=200)), + ('certificate', models.BooleanField(default=False)), + ('lunch_included', models.BooleanField(default=False)), + ('featured', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='WorkshopSlot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('start_time', models.TimeField()), + ('end_time', models.TimeField()), + ('available_spots', models.PositiveIntegerField()), + ('is_available', models.BooleanField(default=True)), + ('workshop', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='experiences.workshop')), + ], + ), + migrations.CreateModel( + name='WorkshopRegistration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('email', models.EmailField(max_length=254)), + ('phone', models.CharField(max_length=15)), + ('number_of_participants', models.PositiveIntegerField()), + ('special_requests', models.TextField(blank=True, null=True)), + ('gst_number', models.CharField(blank=True, max_length=50, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('workshop', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registrations', to='experiences.workshop')), + ('slot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registrations', to='experiences.workshopslot')), + ], + ), + ] diff --git a/basho_backend/apps/experiences/migrations/0005_alter_booking_options_alter_experience_options_and_more.py b/basho_backend/apps/experiences/migrations/0005_alter_booking_options_alter_experience_options_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..85d042babd3c2c59988dbf7008309e3a9220486c --- /dev/null +++ b/basho_backend/apps/experiences/migrations/0005_alter_booking_options_alter_experience_options_and_more.py @@ -0,0 +1,210 @@ +# Generated by Django 6.0 on 2026-01-10 10:20 + +import django.core.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiences', '0004_workshop_workshopslot_workshopregistration'), + ] + + operations = [ + migrations.AlterModelOptions( + name='booking', + options={'ordering': ['-created_at']}, + ), + migrations.AlterModelOptions( + name='experience', + options={'verbose_name_plural': 'Experiences'}, + ), + migrations.AlterModelOptions( + name='studiobooking', + options={'ordering': ['visit_date', 'time_slot'], 'verbose_name_plural': 'Studio Bookings'}, + ), + migrations.AlterModelOptions( + name='upcomingevent', + options={'ordering': ['date']}, + ), + migrations.AlterModelOptions( + name='workshop', + options={'ordering': ['name']}, + ), + migrations.AlterModelOptions( + name='workshopregistration', + options={'ordering': ['-created_at']}, + ), + migrations.AlterModelOptions( + name='workshopslot', + options={'ordering': ['date', 'start_time']}, + ), + migrations.AddField( + model_name='workshop', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name='workshop', + name='updated_at', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name='workshopregistration', + name='payment_amount', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='workshopregistration', + name='payment_status', + field=models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('failed', 'Failed')], default='pending', max_length=20), + ), + migrations.AddField( + model_name='workshopregistration', + name='updated_at', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name='workshopslot', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name='workshopslot', + name='updated_at', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name='booking', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name='workshop', + name='certificate', + field=models.BooleanField(default=False, help_text='Is certificate provided?'), + ), + migrations.AlterField( + model_name='workshop', + name='description', + field=models.TextField(help_text='Short description for cards'), + ), + migrations.AlterField( + model_name='workshop', + name='duration', + field=models.CharField(help_text="e.g., '3 hours', '2-3 hours'", max_length=50), + ), + migrations.AlterField( + model_name='workshop', + name='experience_type', + field=models.CharField(blank=True, choices=[('couples_date', "Couple's Date"), ('birthday_party', 'Birthday Party'), ('corporate', 'Corporate'), ('masterclass', 'Masterclass')], max_length=30, null=True), + ), + migrations.AlterField( + model_name='workshop', + name='featured', + field=models.BooleanField(default=False, help_text='Show as featured workshop?'), + ), + migrations.AlterField( + model_name='workshop', + name='images', + field=models.JSONField(default=list, help_text='List of image URLs/paths'), + ), + migrations.AlterField( + model_name='workshop', + name='includes', + field=models.JSONField(default=list, help_text="List of what's included (materials, tools, etc.)"), + ), + migrations.AlterField( + model_name='workshop', + name='instructor', + field=models.CharField(default='Shivangi', max_length=100), + ), + migrations.AlterField( + model_name='workshop', + name='is_active', + field=models.BooleanField(default=True, help_text='Is workshop available for booking?'), + ), + migrations.AlterField( + model_name='workshop', + name='location', + field=models.CharField(default='Basho Studio', max_length=200), + ), + migrations.AlterField( + model_name='workshop', + name='long_description', + field=models.TextField(help_text='Detailed description for workshop page'), + ), + migrations.AlterField( + model_name='workshop', + name='lunch_included', + field=models.BooleanField(default=False, help_text='Is lunch included?'), + ), + migrations.AlterField( + model_name='workshop', + name='max_participants', + field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AlterField( + model_name='workshop', + name='min_participants', + field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AlterField( + model_name='workshop', + name='price', + field=models.PositiveIntegerField(help_text='Price in rupees'), + ), + migrations.AlterField( + model_name='workshop', + name='price_per_person', + field=models.BooleanField(default=True, help_text='If True, price is per person. If False, price is for the group.'), + ), + migrations.AlterField( + model_name='workshop', + name='provided_materials', + field=models.JSONField(default=list, help_text='Materials provided by studio'), + ), + migrations.AlterField( + model_name='workshop', + name='requirements', + field=models.JSONField(blank=True, help_text='What participants need to bring/know', null=True), + ), + migrations.AlterField( + model_name='workshop', + name='take_home', + field=models.CharField(help_text='What participants can take home', max_length=200), + ), + migrations.AlterField( + model_name='workshopregistration', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name='workshopregistration', + name='number_of_participants', + field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AlterField( + model_name='workshopslot', + name='available_spots', + field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='workshopslot', + name='is_available', + field=models.BooleanField(default=True, help_text='Is this slot open for bookings?'), + ), + migrations.AlterUniqueTogether( + name='workshopslot', + unique_together={('workshop', 'date', 'start_time')}, + ), + migrations.AddIndex( + model_name='workshopregistration', + index=models.Index(fields=['email'], name='experiences_email_b6e066_idx'), + ), + migrations.AddIndex( + model_name='workshopregistration', + index=models.Index(fields=['created_at'], name='experiences_created_ce420a_idx'), + ), + ] diff --git a/basho_backend/apps/experiences/migrations/0006_alter_booking_options_alter_experience_options_and_more.py b/basho_backend/apps/experiences/migrations/0006_alter_booking_options_alter_experience_options_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..ef20b123c8142edac324000b835bf4c27f42f1e4 --- /dev/null +++ b/basho_backend/apps/experiences/migrations/0006_alter_booking_options_alter_experience_options_and_more.py @@ -0,0 +1,201 @@ +# Generated by Django 6.0 on 2026-01-10 11:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiences', '0005_alter_booking_options_alter_experience_options_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='booking', + options={}, + ), + migrations.AlterModelOptions( + name='experience', + options={}, + ), + migrations.AlterModelOptions( + name='studiobooking', + options={}, + ), + migrations.AlterModelOptions( + name='upcomingevent', + options={}, + ), + migrations.AlterModelOptions( + name='workshop', + options={}, + ), + migrations.AlterModelOptions( + name='workshopregistration', + options={}, + ), + migrations.AlterModelOptions( + name='workshopslot', + options={}, + ), + migrations.RemoveIndex( + model_name='workshopregistration', + name='experiences_email_b6e066_idx', + ), + migrations.RemoveIndex( + model_name='workshopregistration', + name='experiences_created_ce420a_idx', + ), + migrations.AlterUniqueTogether( + name='workshopslot', + unique_together=set(), + ), + migrations.RemoveField( + model_name='workshop', + name='created_at', + ), + migrations.RemoveField( + model_name='workshop', + name='updated_at', + ), + migrations.AlterField( + model_name='booking', + name='created_at', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='workshop', + name='certificate', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='workshop', + name='description', + field=models.TextField(), + ), + migrations.AlterField( + model_name='workshop', + name='duration', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='workshop', + name='experience_type', + field=models.CharField(blank=True, choices=[('couples_date', 'Couple’s Date'), ('birthday_party', 'Birthday Party'), ('corporate', 'Corporate'), ('masterclass', 'Masterclass')], max_length=30, null=True), + ), + migrations.AlterField( + model_name='workshop', + name='featured', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='workshop', + name='images', + field=models.JSONField(default=list), + ), + migrations.AlterField( + model_name='workshop', + name='includes', + field=models.JSONField(default=list), + ), + migrations.AlterField( + model_name='workshop', + name='instructor', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='workshop', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='workshop', + name='location', + field=models.CharField(max_length=200), + ), + migrations.AlterField( + model_name='workshop', + name='long_description', + field=models.TextField(), + ), + migrations.AlterField( + model_name='workshop', + name='lunch_included', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='workshop', + name='max_participants', + field=models.PositiveIntegerField(), + ), + migrations.AlterField( + model_name='workshop', + name='min_participants', + field=models.PositiveIntegerField(), + ), + migrations.AlterField( + model_name='workshop', + name='price', + field=models.PositiveIntegerField(), + ), + migrations.AlterField( + model_name='workshop', + name='price_per_person', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='workshop', + name='provided_materials', + field=models.JSONField(default=list), + ), + migrations.AlterField( + model_name='workshop', + name='requirements', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='workshop', + name='take_home', + field=models.CharField(max_length=200), + ), + migrations.AlterField( + model_name='workshopregistration', + name='created_at', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='workshopregistration', + name='number_of_participants', + field=models.PositiveIntegerField(), + ), + migrations.AlterField( + model_name='workshopslot', + name='available_spots', + field=models.PositiveIntegerField(), + ), + migrations.AlterField( + model_name='workshopslot', + name='is_available', + field=models.BooleanField(default=True), + ), + migrations.RemoveField( + model_name='workshopregistration', + name='payment_amount', + ), + migrations.RemoveField( + model_name='workshopregistration', + name='payment_status', + ), + migrations.RemoveField( + model_name='workshopregistration', + name='updated_at', + ), + migrations.RemoveField( + model_name='workshopslot', + name='created_at', + ), + migrations.RemoveField( + model_name='workshopslot', + name='updated_at', + ), + ] diff --git a/basho_backend/apps/experiences/migrations/0007_booking_otp_booking_otp_expires_at_experienceslot_and_more.py b/basho_backend/apps/experiences/migrations/0007_booking_otp_booking_otp_expires_at_experienceslot_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..23ac0d81db5bcdcbe1b69b2da6b24ebceb8636aa --- /dev/null +++ b/basho_backend/apps/experiences/migrations/0007_booking_otp_booking_otp_expires_at_experienceslot_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 6.0 on 2026-01-10 19:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiences', '0006_alter_booking_options_alter_experience_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='booking', + name='otp', + field=models.CharField(blank=True, max_length=6, null=True), + ), + migrations.AddField( + model_name='booking', + name='otp_expires_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.CreateModel( + name='ExperienceSlot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('start_time', models.TimeField()), + ('end_time', models.TimeField()), + ('min_participants', models.PositiveIntegerField()), + ('max_participants', models.PositiveIntegerField()), + ('booked_participants', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('experience', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='experiences.experience')), + ], + options={ + 'ordering': ['date', 'start_time'], + }, + ), + migrations.AddField( + model_name='booking', + name='slot', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='bookings', to='experiences.experienceslot'), + ), + ] diff --git a/basho_backend/apps/experiences/migrations/0008_remove_booking_otp_remove_booking_otp_expires_at_and_more.py b/basho_backend/apps/experiences/migrations/0008_remove_booking_otp_remove_booking_otp_expires_at_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..bb71f8c1d8a5e94db47fda2ebe4bb688a3d63287 --- /dev/null +++ b/basho_backend/apps/experiences/migrations/0008_remove_booking_otp_remove_booking_otp_expires_at_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 6.0 on 2026-01-12 18:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiences', '0007_booking_otp_booking_otp_expires_at_experienceslot_and_more'), + ('orders', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='booking', + name='otp', + ), + migrations.RemoveField( + model_name='booking', + name='otp_expires_at', + ), + migrations.RemoveField( + model_name='booking', + name='payment_status', + ), + migrations.AddField( + model_name='booking', + name='payment_order', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='experience_booking', to='orders.paymentorder'), + ), + migrations.AddField( + model_name='workshopregistration', + name='payment_order', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workshop_registration', to='orders.paymentorder'), + ), + migrations.AddField( + model_name='workshopregistration', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('confirmed', 'Confirmed'), ('failed', 'Failed')], default='pending', max_length=20), + ), + migrations.AlterField( + model_name='booking', + name='number_of_people', + field=models.PositiveIntegerField(), + ), + migrations.AlterField( + model_name='booking', + name='payment_amount', + field=models.PositiveIntegerField(), + ), + migrations.AlterField( + model_name='booking', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('failed', 'Failed')], default='pending', max_length=20), + ), + migrations.AlterField( + model_name='workshopregistration', + name='slot', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='registrations', to='experiences.workshopslot'), + ), + ] diff --git a/basho_backend/apps/experiences/migrations/0009_rename_booked_participants_experienceslot_booked_slots_and_more.py b/basho_backend/apps/experiences/migrations/0009_rename_booked_participants_experienceslot_booked_slots_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..f2aa01110d5d44b20905686630b8262684d6a885 --- /dev/null +++ b/basho_backend/apps/experiences/migrations/0009_rename_booked_participants_experienceslot_booked_slots_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0 on 2026-01-15 09:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiences', '0008_remove_booking_otp_remove_booking_otp_expires_at_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='experienceslot', + old_name='booked_participants', + new_name='booked_slots', + ), + migrations.RenameField( + model_name='experienceslot', + old_name='max_participants', + new_name='total_slots', + ), + migrations.RemoveField( + model_name='experienceslot', + name='min_participants', + ), + migrations.AddField( + model_name='experience', + name='max_participants', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='experience', + name='min_participants', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/basho_backend/apps/experiences/migrations/0010_alter_experience_image.py b/basho_backend/apps/experiences/migrations/0010_alter_experience_image.py new file mode 100644 index 0000000000000000000000000000000000000000..df55b58f9a2f8dde0696a193296fb616ca619e40 --- /dev/null +++ b/basho_backend/apps/experiences/migrations/0010_alter_experience_image.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-01-16 21:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiences', '0009_rename_booked_participants_experienceslot_booked_slots_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='experience', + name='image', + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/basho_backend/apps/experiences/migrations/__init__.py b/basho_backend/apps/experiences/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/experiences/models.py b/basho_backend/apps/experiences/models.py new file mode 100644 index 0000000000000000000000000000000000000000..0bec3d92aaa311c498c517f8bf95422b3fb9597b --- /dev/null +++ b/basho_backend/apps/experiences/models.py @@ -0,0 +1,256 @@ +from django.db import models +from django.core.exceptions import ValidationError + +class Experience(models.Model): + title = models.CharField(max_length=200) + tagline = models.CharField(max_length=200) + description = models.TextField() + duration = models.CharField(max_length=50) + people = models.CharField(max_length=50) + + min_participants = models.PositiveIntegerField(null=True, blank=True) + max_participants = models.PositiveIntegerField(null=True, blank=True) + + price = models.IntegerField() + image = models.JSONField(default=list, blank=True) + is_active = models.BooleanField(default=True) + + + def __str__(self): + return self.title + +class ExperienceSlot(models.Model): + experience = models.ForeignKey( + Experience, + on_delete=models.CASCADE, + related_name="slots" + ) + + date = models.DateField() + start_time = models.TimeField() + end_time = models.TimeField() + total_slots = models.PositiveIntegerField() + booked_slots = models.PositiveIntegerField(default=0) + + is_active = models.BooleanField(default=True) + + def clean(self): + if self.start_time >= self.end_time: + raise ValidationError("Start time must be before end time.") + + if self.booked_slots > self.total_slots: + raise ValidationError("Booked slots cannot exceed total slots.") + + exp = self.experience + if exp.min_participants and exp.max_participants: + if exp.min_participants > exp.max_participants: + raise ValidationError( + "Experience min participants cannot exceed max participants." + ) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.experience.title} | {self.date} {self.start_time}-{self.end_time}" + + class Meta: + ordering = ["date", "start_time"] + +class Booking(models.Model): + STATUS_CHOICES = ( + ("pending", "Pending"), + ("confirmed", "Confirmed"), + ("cancelled", "Cancelled"), + ("failed", "Failed"), + ) + + experience = models.ForeignKey( + Experience, + on_delete=models.CASCADE, + related_name="bookings" + ) + + slot = models.ForeignKey( + ExperienceSlot, + on_delete=models.PROTECT, + related_name="bookings", + null=True, + blank=True, +) + + + full_name = models.CharField(max_length=100) + phone = models.CharField(max_length=15) + email = models.EmailField() + + booking_date = models.DateField() + number_of_people = models.PositiveIntegerField() + + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default="pending" + ) + + payment_amount = models.PositiveIntegerField() + + # 🔗 LINK TO ORDERS APP + payment_order = models.OneToOneField( + "orders.PaymentOrder", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="experience_booking" + ) + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.full_name} - {self.experience.title}" + +class StudioBooking(models.Model): + full_name = models.CharField(max_length=100) + phone = models.CharField(max_length=15) + email = models.EmailField() + visit_date = models.DateField() + time_slot = models.CharField(max_length=50) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.full_name} - {self.visit_date} ({self.time_slot})" + +class UpcomingEvent(models.Model): + title = models.CharField(max_length=200) + date = models.CharField(max_length=50) # simple string for now + location = models.CharField(max_length=200) + description = models.TextField() + badge = models.CharField(max_length=50, default="✨ Upcoming") + + def __str__(self): + return self.title + +class Workshop(models.Model): + WORKSHOP_TYPE_CHOICES = [ + ("group", "Group"), + ("private", "Private"), + ("experience", "Experience"), + ] + + LEVEL_CHOICES = [ + ("beginner", "Beginner"), + ("intermediate", "Intermediate"), + ("advanced", "Advanced"), + ] + + EXPERIENCE_TYPE_CHOICES = [ + ("couples_date", "Couple’s Date"), + ("birthday_party", "Birthday Party"), + ("corporate", "Corporate"), + ("masterclass", "Masterclass"), + ] + + name = models.CharField(max_length=200) + type = models.CharField(max_length=20, choices=WORKSHOP_TYPE_CHOICES) + level = models.CharField(max_length=20, choices=LEVEL_CHOICES) + experience_type = models.CharField( + max_length=30, + choices=EXPERIENCE_TYPE_CHOICES, + blank=True, + null=True + ) + + description = models.TextField() + long_description = models.TextField() + + images = models.JSONField(default=list) + + duration = models.CharField(max_length=50) + + min_participants = models.PositiveIntegerField() + max_participants = models.PositiveIntegerField() + + price = models.PositiveIntegerField() + price_per_person = models.BooleanField(default=True) + + includes = models.JSONField(default=list) + requirements = models.JSONField(blank=True, null=True) + provided_materials = models.JSONField(default=list) + + location = models.CharField(max_length=200) + instructor = models.CharField(max_length=100) + take_home = models.CharField(max_length=200) + + certificate = models.BooleanField(default=False) + lunch_included = models.BooleanField(default=False) + featured = models.BooleanField(default=False) + + is_active = models.BooleanField(default=True) + + def __str__(self): + return self.name + +class WorkshopSlot(models.Model): + workshop = models.ForeignKey( + Workshop, + on_delete=models.CASCADE, + related_name="slots" + ) + + date = models.DateField() + start_time = models.TimeField() + end_time = models.TimeField() + + available_spots = models.PositiveIntegerField() + is_available = models.BooleanField(default=True) + + def __str__(self): + return f"{self.workshop.name} | {self.date} {self.start_time}" + +class WorkshopRegistration(models.Model): + STATUS_CHOICES = ( + ("pending", "Pending"), + ("confirmed", "Confirmed"), + ("failed", "Failed"), + ) + + workshop = models.ForeignKey( + Workshop, + on_delete=models.CASCADE, + related_name="registrations" + ) + + slot = models.ForeignKey( + WorkshopSlot, + on_delete=models.PROTECT, + related_name="registrations" + ) + + name = models.CharField(max_length=100) + email = models.EmailField() + phone = models.CharField(max_length=15) + + number_of_participants = models.PositiveIntegerField() + special_requests = models.TextField(blank=True, null=True) + gst_number = models.CharField(max_length=50, blank=True, null=True) + + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default="pending" + ) + + # 🔗 LINK TO ORDERS APP + payment_order = models.OneToOneField( + "orders.PaymentOrder", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="workshop_registration" + ) + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.name} - {self.workshop.name}" \ No newline at end of file diff --git a/basho_backend/apps/experiences/serializers.py b/basho_backend/apps/experiences/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..78c387c1bef97a6753cf3d09a81f399bea219c98 --- /dev/null +++ b/basho_backend/apps/experiences/serializers.py @@ -0,0 +1,186 @@ +from rest_framework import serializers +from .models import ( + Booking, + Experience, + StudioBooking, + UpcomingEvent, + Workshop, + WorkshopSlot, + WorkshopRegistration, + ExperienceSlot) + +class BookingSerializer(serializers.ModelSerializer): + class Meta: + model = Booking + fields = [ + "id", + "experience", + "slot", + "full_name", + "phone", + "email", + "booking_date", + "number_of_people", + "status", + ] + read_only_fields = ["status"] + + def validate(self, data): + slot = data.get("slot") + people = data.get("number_of_people") + + if not slot: + raise serializers.ValidationError({ + "slot": "Please select a valid time slot." + }) + + if not slot.is_active: + raise serializers.ValidationError({ + "slot": "This slot is no longer available." + }) + + experience = slot.experience + + # ✅ Booking-level rules (from Experience) + if experience.min_participants is not None: + if people < experience.min_participants: + raise serializers.ValidationError({ + "number_of_people": f"Minimum {experience.min_participants} participants required." + }) + + if experience.max_participants is not None: + if people > experience.max_participants: + raise serializers.ValidationError({ + "number_of_people": f"Maximum {experience.max_participants} participants allowed per booking." + }) + + + # ✅ Slot capacity rule + available = slot.total_slots - slot.booked_slots + if people > available: + raise serializers.ValidationError({ + "number_of_people": f"Only {available} slots left for this time." + }) + + return data + +class ExperienceSlotSerializer(serializers.ModelSerializer): + startTime = serializers.TimeField(source="start_time") + endTime = serializers.TimeField(source="end_time") + availableSlots = serializers.SerializerMethodField() + + class Meta: + model = ExperienceSlot + fields = [ + "id", + "date", + "startTime", + "endTime", + "availableSlots", + ] + + def get_availableSlots(self, obj): + return max(0, obj.total_slots - obj.booked_slots) + +class StudioBookingSerializer(serializers.ModelSerializer): + class Meta: + model = StudioBooking + fields = "__all__" + +class UpcomingEventSerializer(serializers.ModelSerializer): + class Meta: + model = UpcomingEvent + fields = "__all__" + +class WorkshopSlotSerializer(serializers.ModelSerializer): + startTime = serializers.TimeField(source="start_time") + endTime = serializers.TimeField(source="end_time") + availableSpots = serializers.IntegerField(source="available_spots") + isAvailable = serializers.BooleanField(source="is_available") + + class Meta: + model = WorkshopSlot + fields = [ + "id", + "date", + "startTime", + "endTime", + "availableSpots", + "isAvailable", + ] + +class WorkshopSerializer(serializers.ModelSerializer): + participants = serializers.SerializerMethodField() + schedule = WorkshopSlotSerializer(source="slots", many=True) + + experienceType = serializers.CharField(source="experience_type", allow_null=True) + longDescription = serializers.CharField(source="long_description") + pricePerPerson = serializers.BooleanField(source="price_per_person") + takeHome = serializers.CharField(source="take_home") + providedMaterials = serializers.JSONField(source="provided_materials") + lunchIncluded = serializers.BooleanField(source="lunch_included") + + class Meta: + model = Workshop + fields = [ + "id", + "name", + "type", + "level", + "experienceType", + "description", + "longDescription", + "images", + "duration", + "participants", + "price", + "pricePerPerson", + "includes", + "requirements", + "location", + "instructor", + "takeHome", + "providedMaterials", + "certificate", + "lunchIncluded", + "featured", + "schedule", + ] + + def get_participants(self, obj): + return { + "min": obj.min_participants, + "max": obj.max_participants, + } + +class WorkshopRegistrationSerializer(serializers.ModelSerializer): + class Meta: + model = WorkshopRegistration + fields = "__all__" + +class ExperienceSerializer(serializers.ModelSerializer): + slots = ExperienceSlotSerializer(many=True, read_only=True) + participants = serializers.SerializerMethodField() + image = serializers.JSONField() + + class Meta: + model = Experience + fields = [ + "id", + "title", + "tagline", + "description", + "duration", + "people", + "price", + "image", + "is_active", + "participants", + "slots", + ] + + def get_participants(self, obj): + return { + "min": obj.min_participants, + "max": obj.max_participants, + } \ No newline at end of file diff --git a/basho_backend/apps/experiences/tests.py b/basho_backend/apps/experiences/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/basho_backend/apps/experiences/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/basho_backend/apps/experiences/urls.py b/basho_backend/apps/experiences/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..a5cbf4b6b7db53d55fb6e324b8e1efe78ef334ef --- /dev/null +++ b/basho_backend/apps/experiences/urls.py @@ -0,0 +1,67 @@ +from django.urls import path +from .views import ( + CreateBookingView, + CreateStudioBookingView, + ListUpcomingEventsView, + ListWorkshopsView, + ReleaseExperienceSlotView, + WorkshopDetailView, + ListWorkshopSlotsView, + CreateWorkshopRegistrationView, + ListExperienceSlotsView, + ListExperiencesView, + ListExperienceAvailableDatesView, + ListExperienceSlotsByDateView, + VerifyExperiencePaymentView, + my_workshops, + my_experiences +) + + + +urlpatterns = [ + # Experiences + path("book/", CreateBookingView.as_view(), name="create-booking"), + path("verify-payment/", VerifyExperiencePaymentView.as_view()), + #path("book/confirm/", ConfirmBookingView.as_view(), name="confirm-booking"), + path( + "/slots/", + ListExperienceSlotsView.as_view(), + name="experience-slots", + ), + path("", ListExperiencesView.as_view(), name="list-experiences"), + path("release-slot/", ReleaseExperienceSlotView.as_view()), + path("/available-dates/", ListExperienceAvailableDatesView.as_view(), name="experience-available-dates"), + path("/slots-by-date/", ListExperienceSlotsByDateView.as_view(), name="experience-slots-by-date"), + path( + "/available-dates/", + ListExperienceAvailableDatesView.as_view(),), + + path( + "/slots-by-date/", + ListExperienceSlotsByDateView.as_view(), + ), + # Studio + path("studio-book/", CreateStudioBookingView.as_view(), name="create-studio-booking"), + + # Events + path("events/", ListUpcomingEventsView.as_view(), name="list-upcoming-events"), + + # Workshops (UNCHANGED) + path("workshops/", ListWorkshopsView.as_view(), name="list-workshops"), + path("workshops//", WorkshopDetailView.as_view(), name="workshop-detail"), + path( + "workshops//slots/", + ListWorkshopSlotsView.as_view(), + name="workshop-slots", + ), + path( + "workshops/register/", + CreateWorkshopRegistrationView.as_view(), + name="workshop-register", + ), + path("my-workshops/", my_workshops), + path("my-experiences/", my_experiences), + + +] diff --git a/basho_backend/apps/experiences/views.py b/basho_backend/apps/experiences/views.py new file mode 100644 index 0000000000000000000000000000000000000000..968d2ed6cc17c46fc3d0686fb3cb6111e6adb6e3 --- /dev/null +++ b/basho_backend/apps/experiences/views.py @@ -0,0 +1,434 @@ +from django.shortcuts import get_object_or_404 +from django.db import transaction +from rest_framework.exceptions import ValidationError +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from django.http import JsonResponse +from django.core.mail import send_mail +from django.conf import settings +from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import api_view, permission_classes +import razorpay +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.conf import settings +from django.db.models import F + + + +from apps.orders.models import PaymentOrder +from .models import ( + Booking, + StudioBooking, + UpcomingEvent, + Workshop, + WorkshopSlot, + WorkshopRegistration, + Experience, + ExperienceSlot, +) +from .serializers import ( + BookingSerializer, + StudioBookingSerializer, + UpcomingEventSerializer, + WorkshopSerializer, + WorkshopSlotSerializer, + WorkshopRegistrationSerializer, + ExperienceSlotSerializer, + ExperienceSerializer, # ✅ ADD THIS +) + +razorpay_client = razorpay.Client( + auth=(settings.RAZORPAY_KEY_ID, settings.RAZORPAY_KEY_SECRET) +) + +# ========================= +# EXPERIENCE BOOKING (PAYMENT FIRST) +# ========================= + +class CreateBookingView(APIView): + def post(self, request): + serializer = BookingSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + slot = serializer.validated_data["slot"] + people = serializer.validated_data["number_of_people"] + experience = serializer.validated_data["experience"] + + # 🔒 ATOMIC BLOCK (prevents race conditions) + with transaction.atomic(): + slot = ExperienceSlot.objects.select_for_update().get(id=slot.id) + + available = slot.total_slots - slot.booked_slots + + if people > available: + raise ValidationError({ + "slot": f"Only {available} slots left for this time slot." + }) + + booking = serializer.save( + status="pending", + payment_amount=experience.price + ) + + # ✅ Reserve seats + slot.booked_slots += people + slot.save() + + + # ✅ CREATE RAZORPAY ORDER + razorpay_order = razorpay_client.order.create({ + "amount": booking.payment_amount * 100, + "currency": "INR", + "payment_capture": 1 + }) + + payment_order = PaymentOrder.objects.create( + user=request.user if request.user.is_authenticated else None, + order_type="EXPERIENCE", + linked_object_id=booking.id, + linked_app="experiences", + amount=booking.payment_amount, + razorpay_order_id=razorpay_order["id"], + ) + + booking.payment_order = payment_order + booking.save() + + return Response({ + "booking_id": booking.id, + "razorpay_order_id": payment_order.razorpay_order_id, + "amount": payment_order.amount, + }, status=status.HTTP_201_CREATED) + + + +razorpay_client = razorpay.Client( + auth=(settings.RAZORPAY_KEY_ID, settings.RAZORPAY_KEY_SECRET) +) + +class ReleaseExperienceSlotView(APIView): + def post(self, request): + booking_id = request.data.get("booking_id") + + if not booking_id: + return Response( + {"error": "booking_id required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + with transaction.atomic(): + booking = Booking.objects.select_for_update().get(id=booking_id) + + # Only release if not confirmed + if booking.status != "pending": + return Response( + {"message": "Booking already processed"}, + status=status.HTTP_200_OK + ) + + slot = booking.slot + slot.booked_slots -= booking.number_of_people + slot.save() + + + booking.status = "failed" + booking.save() + + return Response( + {"message": "Slot released successfully"}, + status=status.HTTP_200_OK + ) + + except Booking.DoesNotExist: + return Response( + {"error": "Booking not found"}, + status=status.HTTP_404_NOT_FOUND + ) + +class VerifyExperiencePaymentView(APIView): + def post(self, request): + data = request.data + + try: + # 1️⃣ Verify signature + razorpay_client.utility.verify_payment_signature({ + "razorpay_order_id": data["razorpay_order_id"], + "razorpay_payment_id": data["razorpay_payment_id"], + "razorpay_signature": data["razorpay_signature"], + }) + + # 2️⃣ Fetch payment order + payment_order = PaymentOrder.objects.get( + razorpay_order_id=data["razorpay_order_id"] + ) + + # 3️⃣ Mark payment as paid + payment_order.status = "PAID" + payment_order.razorpay_payment_id = data["razorpay_payment_id"] + payment_order.save() + + # 4️⃣ Confirm booking + booking = Booking.objects.get(id=payment_order.linked_object_id) + booking.status = "confirmed" + booking.save() + + # 🔍 TEMP DEBUG — add just above send_mail + print("Experience fields:") + print(booking.experience._meta.get_fields()) + + # 5️⃣ Send email + if booking.email: + send_mail( + subject="Your Experience Booking is Confirmed 🎉", + message=f""" +Hi {booking.full_name}, + +Your experience booking has been successfully confirmed! + +📅 Date: {booking.booking_date} +🎨 Experience: {booking.experience.title} +💰 Amount Paid: ₹{booking.payment_amount} + +We look forward to welcoming you ✨ + +– Team Basho +""", + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[booking.email], + fail_silently=False, + ) + + return Response({ + "message": "Successfully placed order / booked experience" + }, status=status.HTTP_200_OK) + + except razorpay.errors.SignatureVerificationError: + return Response( + {"error": "Payment verification failed"}, + status=status.HTTP_400_BAD_REQUEST + ) + +# ========================= +# STUDIO BOOKING (NO PAYMENT) +# ========================= +@method_decorator(csrf_exempt, name="dispatch") +class CreateStudioBookingView(APIView): + def post(self, request): + serializer = StudioBookingSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + booking = serializer.save() + + try: + send_mail( + subject="Your Studio Visit is Confirmed ✨", + message=( + f"Hi {booking.full_name},\n\n" + f"Your studio visit has been confirmed.\n\n" + f"Date: {booking.visit_date}\n" + f"Time Slot: {booking.time_slot}\n\n" + f"– Basho Studio" + ), + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[booking.email], + fail_silently=True, # 🔥 THIS IS THE KEY + ) + except Exception as e: + print("⚠️ Studio email failed:", str(e)) + + + return Response( + {"message": "Studio booking confirmed"}, + status=status.HTTP_201_CREATED + ) + + +# ========================= +# LISTING VIEWS +# ========================= + +class ListUpcomingEventsView(APIView): + def get(self, request): + events = UpcomingEvent.objects.all() + serializer = UpcomingEventSerializer(events, many=True) + return Response(serializer.data) + + +class ListWorkshopsView(APIView): + def get(self, request): + workshops = Workshop.objects.filter(is_active=True) + serializer = WorkshopSerializer(workshops, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class WorkshopDetailView(APIView): + def get(self, request, workshop_id): + workshop = get_object_or_404(Workshop, id=workshop_id, is_active=True) + serializer = WorkshopSerializer(workshop) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class ListWorkshopSlotsView(APIView): + def get(self, request, workshop_id): + workshop = get_object_or_404(Workshop, id=workshop_id, is_active=True) + slots = WorkshopSlot.objects.filter( + workshop=workshop, + is_available=True + ).order_by("date", "start_time") + + serializer = WorkshopSlotSerializer(slots, many=True) + return Response(serializer.data) + + +class ListExperienceSlotsView(APIView): + def get(self, request, experience_id): + experience = get_object_or_404(Experience, id=experience_id, is_active=True) + slots = ExperienceSlot.objects.filter( + experience=experience, + is_active=True + ).order_by("date", "start_time") + + serializer = ExperienceSlotSerializer(slots, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + +class ListExperiencesView(APIView): + def get(self, request): + experiences = Experience.objects.filter(is_active=True) + serializer = ExperienceSerializer(experiences, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + +class ListExperienceAvailableDatesView(APIView): + def get(self, request, experience_id): + experience = get_object_or_404( + Experience, + id=experience_id, + is_active=True + ) + + # Slots that still have availability + slots = ( + ExperienceSlot.objects + .filter( + experience=experience, + is_active=True, + total_slots__gt=F("booked_slots") + ) + .values_list("date", flat=True) + .distinct() + .order_by("date") + ) + + # Convert dates to string (frontend-friendly) + dates = [d.isoformat() for d in slots] + + return Response(dates, status=status.HTTP_200_OK) + +class ListExperienceSlotsByDateView(APIView): + def get(self, request, experience_id): + date = request.query_params.get("date") + + if not date: + return Response( + {"error": "date query param is required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + experience = get_object_or_404( + Experience, + id=experience_id, + is_active=True + ) + + slots = ( + ExperienceSlot.objects + .filter( + experience=experience, + date=date, + is_active=True + ) + .order_by("start_time") + ) + + serializer = ExperienceSlotSerializer(slots, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + +# ========================= +# WORKSHOP REGISTRATION (PAYMENT FIRST) +# ========================= + +class CreateWorkshopRegistrationView(APIView): + def post(self, request): + serializer = WorkshopRegistrationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + registration = serializer.save(status="pending") + + amount = ( + registration.workshop.price * registration.number_of_participants + if registration.workshop.price_per_person + else registration.workshop.price + ) + + razorpay_order = razorpay_client.order.create({ + "amount": amount * 100, # paise + "currency": "INR", + "payment_capture": 1 + }) + + payment_order = PaymentOrder.objects.create( + user=request.user if request.user.is_authenticated else None, + order_type="WORKSHOP", + linked_object_id=registration.id, + linked_app="experiences", + amount=amount, + razorpay_order_id=razorpay_order["id"], + ) + + registration.payment_order = payment_order + registration.save() + + return Response( + { + "registration_id": registration.id, + "razorpay_order_id": payment_order.razorpay_order_id, + "amount": amount, + }, + status=status.HTTP_201_CREATED + ) +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def my_workshops(request): + orders = PaymentOrder.objects.filter( + user=request.user, + order_type="WORKSHOP", + status="PAID" + ).order_by("-created_at") + + data = [{ + "id": o.id, + "amount": o.amount, + "date": o.created_at, + "linked_object_id": o.linked_object_id + } for o in orders] + + return JsonResponse({"workshops": data}) +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def my_experiences(request): + orders = PaymentOrder.objects.filter( + user=request.user, + order_type="EXPERIENCE", + status="PAID" + ).order_by("-created_at") + + data = [{ + "id": o.id, + "amount": o.amount, + "date": o.created_at, + "linked_object_id": o.linked_object_id + } for o in orders] + + return JsonResponse({"experiences": data}) \ No newline at end of file diff --git a/basho_backend/apps/main/__init__.py b/basho_backend/apps/main/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/main/admin.py b/basho_backend/apps/main/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..8c38f3f3dad51e4585f3984282c2a4bec5349c1e --- /dev/null +++ b/basho_backend/apps/main/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/basho_backend/apps/main/apps.py b/basho_backend/apps/main/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..dea620b9e71adb152198d4b5bde5d05096b4a8cb --- /dev/null +++ b/basho_backend/apps/main/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MainConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.main' diff --git a/basho_backend/apps/main/migrations/__init__.py b/basho_backend/apps/main/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/main/models.py b/basho_backend/apps/main/models.py new file mode 100644 index 0000000000000000000000000000000000000000..71a836239075aa6e6e4ecb700e9c42c95c022d91 --- /dev/null +++ b/basho_backend/apps/main/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/basho_backend/apps/main/templates/main/home.html b/basho_backend/apps/main/templates/main/home.html new file mode 100644 index 0000000000000000000000000000000000000000..12004cad2e138241b1e940de24b21d6e73c4437a --- /dev/null +++ b/basho_backend/apps/main/templates/main/home.html @@ -0,0 +1,337 @@ + + + + +Basho by Shivangi — Analytics + + + + + + + + +
+ + Admin Panel + + +
+

BASHO BYY SHIVANGI

+

Studio Control Dashboard

+

Business & Studio Analytics

+
+ + +
+
+
Total Revenue
+
₹{{ total_revenue }}
+
All paid orders
+
+ +
+
Last 30 Days
+
₹{{ monthly_revenue }}
+
Current growth window
+
+ +
+
Orders Today
+
{{ orders_today }}
+
Product purchases
+
+ +
+
Corporate Leads
+
{{ corporate_inquiries }}
+
Brand & gifting inquiries
+
+
+ + +
+

Revenue Flow (Last 30 Days)

+ +
+ + +
+ +
+
Products
+
{{ total_products }}
+
Low stock: {{ low_stock_products }}
+
+ +
+
Experience Bookings
+
{{ experience_bookings }}
+
Confirmed only
+
+ +
+
Workshop Registrations
+
{{ workshop_registrations }}
+
Paid participants
+
+ +
+
Studio Visits
+
{{ studio_visits }}
+
Total bookings
+
+ +
+
Custom Orders
+
{{ custom_orders }}
+
Pending: {{ pending_custom_orders }}
+
+ +
+
Total Orders
+
{{ total_orders }}
+
All product orders
+
+
+
Total Users
+
{{ total_users }}
+
Registered accounts
+
+ +
+
Reviews
+
{{ total_reviews }}
+
Customer feedback
+
+ + +
+ +
+ + + + + + diff --git a/basho_backend/apps/main/tests.py b/basho_backend/apps/main/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/basho_backend/apps/main/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/basho_backend/apps/main/views.py b/basho_backend/apps/main/views.py new file mode 100644 index 0000000000000000000000000000000000000000..a4493b75d8109743e63df96284e964b596754f5f --- /dev/null +++ b/basho_backend/apps/main/views.py @@ -0,0 +1,116 @@ +from datetime import date, timedelta +from django.db.models import Sum, Count +from django.db.models.functions import TruncDate +from django.shortcuts import render +from django.contrib.auth.decorators import user_passes_test +from apps.accounts.models import User +from apps.reviews.models import Review + +from apps.orders.models import Order, PaymentOrder +from apps.products.models import Product, CustomOrder +from apps.experiences.models import ( + Booking, + WorkshopRegistration, + StudioBooking, +) +from apps.corporate.models import CorporateInquiry + + +def is_admin(user): + return user.is_authenticated and user.is_staff + + +@user_passes_test(is_admin, login_url="/admin/login/") +def home(request): + today = date.today() + last_30_days = today - timedelta(days=30) + + # ======================= + # 💰 REVENUE ANALYTICS + # ======================= + + total_revenue = PaymentOrder.objects.filter( + status="PAID" + ).aggregate(total=Sum("amount"))["total"] or 0 + + monthly_revenue = PaymentOrder.objects.filter( + status="PAID", + created_at__date__gte=last_30_days + ).aggregate(total=Sum("amount"))["total"] or 0 + + # ======================= + # 📦 PRODUCT ANALYTICS + # ======================= + + total_products = Product.objects.count() + low_stock_products = Product.objects.filter(stock__lte=5).count() + + total_orders = Order.objects.count() + orders_today = Order.objects.filter(created_at__date=today).count() + + # ======================= + # 🏺 EXPERIENCE & WORKSHOPS + # ======================= + + experience_bookings = Booking.objects.filter(status="confirmed").count() + workshop_registrations = WorkshopRegistration.objects.filter(status="confirmed").count() + studio_visits = StudioBooking.objects.count() + + # ======================= + # 🧾 CUSTOM & CORPORATE + # ======================= + + custom_orders = CustomOrder.objects.count() + pending_custom_orders = CustomOrder.objects.filter(status="pending").count() + + corporate_inquiries = CorporateInquiry.objects.count() + total_users = User.objects.count() + total_reviews = Review.objects.count() + # ======================= + # 📈 REVENUE GRAPH (30 days) + # ======================= + + revenue_chart = ( + PaymentOrder.objects + .filter(status="PAID", created_at__date__gte=last_30_days) + .annotate(day=TruncDate("created_at")) + .values("day") + .annotate(total=Sum("amount")) + .order_by("day") + ) + + revenue_labels = [str(r["day"]) for r in revenue_chart] + revenue_values = [float(r["total"]) for r in revenue_chart] + + context = { + # money + "total_revenue": round(total_revenue, 2), + "monthly_revenue": round(monthly_revenue, 2), + + # products + "total_products": total_products, + "low_stock_products": low_stock_products, + "total_orders": total_orders, + "orders_today": orders_today, + + # experiences + "experience_bookings": experience_bookings, + "workshop_registrations": workshop_registrations, + "studio_visits": studio_visits, + + # custom & corporate + "custom_orders": custom_orders, + "pending_custom_orders": pending_custom_orders, + "corporate_inquiries": corporate_inquiries, + + # users & reviews + "total_users": total_users, + "total_reviews": total_reviews, + + # charts + "revenue_labels": revenue_labels, + "revenue_values": revenue_values, + + } + + return render(request, "main/home.html", context) diff --git a/basho_backend/apps/orders/__init__.py b/basho_backend/apps/orders/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/orders/admin.py b/basho_backend/apps/orders/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..2bc65b073c27b5fce7ef15a7a749cf37a8781385 --- /dev/null +++ b/basho_backend/apps/orders/admin.py @@ -0,0 +1,118 @@ +from django.contrib import admin + +from django.contrib import admin +from .models import ( + Cart, + CartItem, + PaymentOrder, + Order, + OrderItem, + Payment, + Transaction, +) + +# ========================= +# CART +# ========================= + +class CartItemInline(admin.TabularInline): + model = CartItem + extra = 0 + readonly_fields = ("product", "quantity") + + +@admin.register(Cart) +class CartAdmin(admin.ModelAdmin): + list_display = ("id", "user", "is_active", "created_at") + list_filter = ("is_active", "created_at") + search_fields = ("user__email", "user__username") + readonly_fields = ("created_at",) + inlines = [CartItemInline] + + +# ========================= +# MASTER PAYMENT ORDER +# ========================= + +@admin.register(PaymentOrder) +class PaymentOrderAdmin(admin.ModelAdmin): + list_display = ( + "id", + "order_type", + "user", + "amount", + "status", + "razorpay_order_id", + "created_at", + ) + list_filter = ("order_type", "status", "created_at") + search_fields = ("razorpay_order_id", "user__email", "user__username") + readonly_fields = ("created_at",) + + +# ========================= +# PRODUCT ORDER +# ========================= + +class OrderItemInline(admin.TabularInline): + model = OrderItem + extra = 0 + readonly_fields = ( + "product_name", + "price", + "quantity", + "weight_kg", + ) + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + list_display = ( + "id", + "full_name", + "email", + "phone", + "status", + "total_amount", + "created_at", + ) + list_filter = ("status", "created_at", "city") + search_fields = ("full_name", "email", "phone") + readonly_fields = ("created_at",) + inlines = [OrderItemInline] + + +# ========================= +# PAYMENT +# ========================= + +class TransactionInline(admin.TabularInline): + model = Transaction + extra = 0 + readonly_fields = ("event", "response", "created_at") + + +@admin.register(Payment) +class PaymentAdmin(admin.ModelAdmin): + list_display = ( + "id", + "payment_order", + "razorpay_payment_id", + "status", + "created_at", + ) + list_filter = ("status", "created_at") + search_fields = ("razorpay_payment_id",) + readonly_fields = ("created_at",) + inlines = [TransactionInline] + + +# ========================= +# TRANSACTIONS +# ========================= + +@admin.register(Transaction) +class TransactionAdmin(admin.ModelAdmin): + list_display = ("id", "payment", "event", "created_at") + list_filter = ("event", "created_at") + readonly_fields = ("event", "response", "created_at") \ No newline at end of file diff --git a/basho_backend/apps/orders/apps.py b/basho_backend/apps/orders/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..19bbb4a7c277c5d6a220038ad530134e76e189a2 --- /dev/null +++ b/basho_backend/apps/orders/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OrdersConfig(AppConfig): + name = "apps.orders" diff --git a/basho_backend/apps/orders/migrations/0001_initial.py b/basho_backend/apps/orders/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..4743efb6866f1c8f98b34701a59e34b1f7e50398 --- /dev/null +++ b/basho_backend/apps/orders/migrations/0001_initial.py @@ -0,0 +1,256 @@ +# Generated by Django 6.0 on 2026-01-11 04:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("products", "0006_alter_customorder_email_verification_token"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Order", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("created", "Created"), + ("paid", "Paid"), + ("failed", "Failed"), + ], + default="created", + max_length=20, + ), + ), + ("full_name", models.CharField(max_length=200)), + ("email", models.EmailField(max_length=254)), + ("phone", models.CharField(max_length=20)), + ("address", models.TextField()), + ("city", models.CharField(max_length=100)), + ("pincode", models.CharField(max_length=10)), + ("gst_number", models.CharField(blank=True, max_length=20, null=True)), + ("subtotal", models.FloatField()), + ("shipping_cost", models.FloatField()), + ("total_weight", models.FloatField()), + ("total_amount", models.FloatField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name="Cart", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="CartItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("quantity", models.PositiveIntegerField(default=1)), + ( + "cart", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="orders.cart", + ), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="products.product", + ), + ), + ], + ), + migrations.CreateModel( + name="OrderItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("product_name", models.CharField(max_length=200)), + ("price", models.FloatField()), + ("quantity", models.PositiveIntegerField()), + ("weight_kg", models.FloatField()), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="orders.order", + ), + ), + ], + ), + migrations.CreateModel( + name="PaymentOrder", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "order_type", + models.CharField( + choices=[ + ("PRODUCT", "Product"), + ("WORKSHOP", "Workshop"), + ("EXPERIENCE", "Experience"), + ("CUSTOM", "Custom"), + ], + max_length=20, + ), + ), + ( + "linked_object_id", + models.PositiveIntegerField(blank=True, null=True), + ), + ("linked_app", models.CharField(blank=True, max_length=50)), + ("amount", models.FloatField()), + ( + "status", + models.CharField( + choices=[ + ("PENDING", "Pending"), + ("PAID", "Paid"), + ("FAILED", "Failed"), + ], + default="PENDING", + max_length=20, + ), + ), + ( + "razorpay_order_id", + models.CharField(blank=True, max_length=200, null=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="Payment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "razorpay_payment_id", + models.CharField(blank=True, max_length=200, null=True), + ), + ("razorpay_signature", models.TextField(blank=True, null=True)), + ("status", models.CharField(default="created", max_length=50)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "payment_order", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="payment", + to="orders.paymentorder", + ), + ), + ], + ), + migrations.AddField( + model_name="order", + name="payment_order", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="product_order", + to="orders.paymentorder", + ), + ), + migrations.CreateModel( + name="Transaction", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("event", models.CharField(max_length=100)), + ("response", models.JSONField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "payment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="transactions", + to="orders.payment", + ), + ), + ], + ), + ] diff --git a/basho_backend/apps/orders/migrations/0002_orderitem_product.py b/basho_backend/apps/orders/migrations/0002_orderitem_product.py new file mode 100644 index 0000000000000000000000000000000000000000..ba8b60060bc453599f0741be622cbc54f3eeb215 --- /dev/null +++ b/basho_backend/apps/orders/migrations/0002_orderitem_product.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0 on 2026-01-15 13:31 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0001_initial'), + ('products', '0006_alter_customorder_email_verification_token'), + ] + + operations = [ + migrations.AddField( + model_name='orderitem', + name='product', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='products.product'), + ), + ] diff --git a/basho_backend/apps/orders/migrations/__init__.py b/basho_backend/apps/orders/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/orders/models.py b/basho_backend/apps/orders/models.py new file mode 100644 index 0000000000000000000000000000000000000000..2bcea64f8b955758e25de86b43f304c56b8c0762 --- /dev/null +++ b/basho_backend/apps/orders/models.py @@ -0,0 +1,161 @@ +from django.db import models + +from django.db import models +from django.conf import settings +from django.core.validators import FileExtensionValidator + + +# ========================= +# CART (ONLY FOR PRODUCTS) +# ========================= + +class Cart(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + def total_weight(self): + return sum(item.product.weight * item.quantity for item in self.items.all()) + + def total_price(self): + return sum(item.product.price * item.quantity for item in self.items.all()) + + def __str__(self): + return f"Cart {self.id} - {self.user}" + + +class CartItem(models.Model): + cart = models.ForeignKey(Cart, related_name="items", on_delete=models.CASCADE) + product = models.ForeignKey("products.Product", on_delete=models.CASCADE) + quantity = models.PositiveIntegerField(default=1) + + def __str__(self): + return f"{self.product.name} ({self.quantity})" + + +# ================================== +# MASTER PAYMENT ORDER (ALL MODULES) +# ================================== + +class PaymentOrder(models.Model): + + TYPE = ( + ("PRODUCT", "Product"), + ("WORKSHOP", "Workshop"), + ("EXPERIENCE", "Experience"), + ("CUSTOM", "Custom"), + ) + + STATUS = ( + ("PENDING", "Pending"), + ("PAID", "Paid"), + ("FAILED", "Failed"), + ) + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True) + order_type = models.CharField(max_length=20, choices=TYPE) + + linked_object_id = models.PositiveIntegerField(null=True, blank=True) + linked_app = models.CharField(max_length=50, blank=True) + + amount = models.FloatField() + status = models.CharField(max_length=20, choices=STATUS, default="PENDING") + + razorpay_order_id = models.CharField(max_length=200, blank=True, null=True) + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.order_type} - {self.id} - {self.status}" + + +# ================================== +# PRODUCT ORDER +# ================================== + +class Order(models.Model): + + STATUS_CHOICES = ( + ("created", "Created"), + ("paid", "Paid"), + ("failed", "Failed"), + ) + + payment_order = models.OneToOneField( + PaymentOrder, + on_delete=models.CASCADE, + related_name="product_order" + ) + + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="created") + + full_name = models.CharField(max_length=200) + email = models.EmailField() + phone = models.CharField(max_length=20) + + address = models.TextField() + city = models.CharField(max_length=100) + pincode = models.CharField(max_length=10) + + gst_number = models.CharField(max_length=20, blank=True, null=True) + + subtotal = models.FloatField() + shipping_cost = models.FloatField() + total_weight = models.FloatField() + total_amount = models.FloatField() + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Order #{self.id} - {self.full_name}" + + +class OrderItem(models.Model): + + order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE) + product = models.ForeignKey("products.Product", on_delete=models.SET_NULL, null=True) + + product_name = models.CharField(max_length=200) + price = models.FloatField() + quantity = models.PositiveIntegerField() + weight_kg = models.FloatField() + + def total_price(self): + return self.price * self.quantity + + def __str__(self): + return f"{self.product_name} ({self.quantity})" + + +# ========================= +# PAYMENT & TRANSACTIONS +# ========================= + +class Payment(models.Model): + + payment_order = models.OneToOneField( + PaymentOrder, + on_delete=models.CASCADE, + related_name="payment" + ) + + razorpay_payment_id = models.CharField(max_length=200, blank=True, null=True) + razorpay_signature = models.TextField(blank=True, null=True) + + status = models.CharField(max_length=50, default="created") + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Payment {self.id} - {self.status}" + + +class Transaction(models.Model): + + payment = models.ForeignKey(Payment, on_delete=models.CASCADE, related_name="transactions") + event = models.CharField(max_length=100) + response = models.JSONField() + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.event} - {self.id}" \ No newline at end of file diff --git a/basho_backend/apps/orders/tests.py b/basho_backend/apps/orders/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/basho_backend/apps/orders/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/basho_backend/apps/orders/urls.py b/basho_backend/apps/orders/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..ea1ddd28f947694b045af17e62040c1ab96c9371 --- /dev/null +++ b/basho_backend/apps/orders/urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from apps.orders.views.cart import add_to_cart, get_cart, remove_from_cart,clear_cart,update_cart,sync_cart +from apps.orders.views.checkout import create_product_order,my_orders +from apps.orders.views.payments import verify_payment +urlpatterns = [ + path("cart/", get_cart), + path("cart/add/", add_to_cart), + path("cart/update/", update_cart), + path("cart/remove/", remove_from_cart), + path("cart/clear/", clear_cart), + path("cart/remove//", remove_from_cart), + path("checkout/product/", create_product_order), + path("payment/verify/", verify_payment), + path("my-orders/",my_orders ), + path("cart/sync/", sync_cart), + +] \ No newline at end of file diff --git a/basho_backend/apps/orders/views.py b/basho_backend/apps/orders/views.py new file mode 100644 index 0000000000000000000000000000000000000000..91ea44a218fbd2f408430959283f0419c921093e --- /dev/null +++ b/basho_backend/apps/orders/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/basho_backend/apps/orders/views/__init__.py b/basho_backend/apps/orders/views/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/orders/views/cart.py b/basho_backend/apps/orders/views/cart.py new file mode 100644 index 0000000000000000000000000000000000000000..bed78f91f14c5f5c3ba6b2dbd8c04aa4ac0bb899 --- /dev/null +++ b/basho_backend/apps/orders/views/cart.py @@ -0,0 +1,166 @@ +import json +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt + +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + +from apps.orders.models import Cart, CartItem +from apps.products.models import Product + + +# ------------------------ +# HELPERS +# ------------------------ + +def get_or_create_cart(request): + if request.user.is_authenticated: + cart, _ = Cart.objects.get_or_create(user=request.user, is_active=True) + return cart + + cart_id = request.session.get("cart_id") + if cart_id: + try: + return Cart.objects.get(id=cart_id, is_active=True) + except Cart.DoesNotExist: + pass + + cart = Cart.objects.create(is_active=True) + request.session["cart_id"] = cart.id + return cart + + +# ------------------------ +# GET CART +# ------------------------ + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def get_cart(request): + cart = get_or_create_cart(request) + + items = [] + for item in cart.items.select_related("product"): + items.append({ + "id": item.id, + "product_id": item.product.id, + "name": item.product.name, + "price": float(item.product.price), + "stock": item.product.stock, + "image": item.product.image.url if item.product.image else "", + "quantity": item.quantity + }) + + return Response({ + "cart_id": cart.id, + "items": items, + "total_price": cart.total_price(), + "total_weight": cart.total_weight() + }) + + +# ------------------------ +# ADD TO CART +# ------------------------ + +@csrf_exempt +@api_view(["POST"]) +@permission_classes([AllowAny]) +def add_to_cart(request): + data = json.loads(request.body or "{}") + product_id = data.get("product_id") + qty = int(data.get("quantity", 1)) + + product = Product.objects.get(id=product_id) + cart = get_or_create_cart(request) + + item, _ = CartItem.objects.get_or_create(cart=cart, product=product) + item.quantity = min(item.quantity + qty, product.stock) + item.save() + + return Response({"message": "Item added"}) + + +# ------------------------ +# UPDATE CART +# ------------------------ + +@csrf_exempt +@api_view(["POST"]) +@permission_classes([AllowAny]) +def update_cart(request): + data = json.loads(request.body or "{}") + item_id = data.get("item_id") + qty = int(data.get("quantity", 1)) + + item = CartItem.objects.get(id=item_id) + + if qty <= 0: + item.delete() + else: + item.quantity = min(qty, item.product.stock) + item.save() + + return Response({"message": "Cart updated"}) + + +# ------------------------ +# REMOVE ITEM +# ------------------------ + +@csrf_exempt +@api_view(["POST"]) +@permission_classes([AllowAny]) +def remove_from_cart(request): + data = json.loads(request.body or "{}") + item_id = data.get("item_id") + + CartItem.objects.filter(id=item_id).delete() + return Response({"message": "Item removed"}) + + +# ------------------------ +# CLEAR CART +# ------------------------ + +@csrf_exempt +@api_view(["POST"]) +@permission_classes([AllowAny]) +def clear_cart(request): + cart = get_or_create_cart(request) + cart.items.all().delete() + return Response({"message": "Cart cleared"}) + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def sync_cart(request): + """ + Receive frontend cart and sync it to backend DB for logged-in user. + Payload example: + { + "items": [ + {"product_id": 1, "quantity": 2}, + {"product_id": 5, "quantity": 1} + ] + } + """ + data = request.data + items = data.get("items", []) + + if not items: + return Response({"message": "No items to sync"}, status=400) + + cart, _ = Cart.objects.get_or_create(user=request.user, is_active=True) + cart.items.all().delete() # clear old cart + + for item in items: + try: + product = Product.objects.get(id=item["product_id"]) + qty = min(int(item.get("quantity", 1)), product.stock) + if qty > 0: + CartItem.objects.create(cart=cart, product=product, quantity=qty) + except Product.DoesNotExist: + continue + + return Response({"message": "Cart synced", "cart_id": cart.id}) \ No newline at end of file diff --git a/basho_backend/apps/orders/views/checkout.py b/basho_backend/apps/orders/views/checkout.py new file mode 100644 index 0000000000000000000000000000000000000000..0369649aff11f7befc1111dec9a7f6373f072f93 --- /dev/null +++ b/basho_backend/apps/orders/views/checkout.py @@ -0,0 +1,161 @@ +import json +import razorpay + +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.conf import settings +from django.db import transaction +from django.core.mail import send_mail + +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated + +from apps.orders.models import ( + Cart, Order, OrderItem, + PaymentOrder, Payment, Transaction +) +from apps.products.models import Product + + +client = razorpay.Client(auth=(settings.RAZORPAY_KEY_ID, settings.RAZORPAY_KEY_SECRET)) + + +# ==================================================== +# CREATE ORDER + CREATE RAZORPAY ORDER +# LOGIN REQUIRED +# ==================================================== + +@csrf_exempt +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@transaction.atomic +def create_product_order(request): + + data = json.loads(request.body or "{}") + customer = data.get("customer") + + if not customer: + return JsonResponse({"error": "Customer data missing"}, status=400) + + # 🔐 LOCK CART + cart = Cart.objects.select_for_update().filter( + user=request.user, + is_active=True + ).first() + + if not cart or not cart.items.exists(): + return JsonResponse({"error": "Cart is empty"}, status=400) + + subtotal = 0 + total_weight = 0 + validated_items = [] + + # 🔒 LOCK PRODUCTS (ANTI-OVERSELL) + for item in cart.items.select_related("product"): + product = Product.objects.select_for_update().get(id=item.product.id) + + if product.stock < item.quantity: + return JsonResponse( + {"error": f"{product.name} is out of stock"}, + status=400 + ) + + subtotal += product.price * item.quantity + total_weight += product.weight * item.quantity + + validated_items.append((item, product)) + + shipping_cost = 50 + gst = round((subtotal + shipping_cost) * 0.18, 2) + total_amount = subtotal + shipping_cost + gst + + + # ========================= + # MASTER PAYMENT ORDER + # ========================= + payment_order = PaymentOrder.objects.create( + user=request.user, + order_type="PRODUCT", + amount=total_amount, + status="PENDING" + ) + + # ========================= + # RAZORPAY ORDER + # ========================= + razorpay_order = client.order.create({ + "amount": int(round(total_amount * 100)), + "currency": "INR", + "payment_capture": 1 + }) + + payment_order.razorpay_order_id = razorpay_order["id"] + payment_order.save() + + # ========================= + # PRODUCT ORDER + # ========================= + order = Order.objects.create( + payment_order=payment_order, + full_name=customer["fullName"], + email=customer["email"], + phone=customer["phone"], + address=customer["address"], + city=customer["city"], + pincode=customer["pincode"], + gst_number=customer.get("gstNumber"), + subtotal=subtotal, + shipping_cost=shipping_cost, + total_weight=total_weight, + total_amount=total_amount + ) + + payment_order.linked_object_id = order.id + payment_order.linked_app = "orders" + payment_order.save() + + # ========================= + # ORDER ITEMS SNAPSHOT + # ========================= + for item, product in validated_items: + OrderItem.objects.create( + order=order, + product=product, + product_name=product.name, + price=product.price, + quantity=item.quantity, + weight_kg=product.weight + ) + + return JsonResponse({ + "order_id": order.id, + "razorpay_order_id": razorpay_order["id"], + "amount": int(total_amount * 100), + "currency": "INR", + "key": settings.RAZORPAY_KEY_ID + }) + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def my_orders(request): + orders = Order.objects.filter( + payment_order__user=request.user + ).order_by("-created_at") + + data = [] + for o in orders: + data.append({ + "id": o.id, + "total": o.total_amount, + "status": o.status, + "created_at": o.created_at, + "items": [ + { + "name": i.product_name, + "qty": i.quantity, + "price": i.price + } for i in o.items.all() + ] + }) + + return JsonResponse({"orders": data}) \ No newline at end of file diff --git a/basho_backend/apps/orders/views/payments.py b/basho_backend/apps/orders/views/payments.py new file mode 100644 index 0000000000000000000000000000000000000000..23688010e384d756c12883f4910941dcffcc3973 --- /dev/null +++ b/basho_backend/apps/orders/views/payments.py @@ -0,0 +1,261 @@ +import json +import razorpay +import os +from rest_framework.decorators import api_view, permission_classes +from django.conf import settings +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.db import transaction + +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from email.mime.image import MIMEImage +from apps.orders.models import Cart +from apps.orders.models import PaymentOrder, Payment, Transaction +from apps.orders.models import OrderItem +from apps.products.models import Product +from apps.experiences.models import Booking, WorkshopRegistration +from django.contrib.staticfiles import finders + + +os.environ["PYTHONHTTPSVERIFY"] = "1" + +client = razorpay.Client( + auth=(settings.RAZORPAY_KEY_ID, settings.RAZORPAY_KEY_SECRET) +) + +# ==================================================== +# EXPERIENCE CONFIRMATION +# ==================================================== + +def confirm_experience_booking(payment_order): + with transaction.atomic(): + booking = Booking.objects.select_for_update().get( + id=payment_order.linked_object_id + ) + + if booking.status == "confirmed": + return + + slot = booking.slot + people = booking.number_of_people + + if slot.booked_participants + people > slot.max_participants: + raise Exception("Experience slot is full") + + slot.booked_participants += people + slot.save() + + booking.status = "confirmed" + booking.save() + + +# ==================================================== +# WORKSHOP CONFIRMATION +# ==================================================== + +def confirm_workshop_registration(payment_order): + with transaction.atomic(): + registration = WorkshopRegistration.objects.select_for_update().get( + id=payment_order.linked_object_id + ) + + if registration.status == "confirmed": + return + + slot = registration.slot + people = registration.number_of_participants + + if people > slot.available_spots: + raise Exception("Workshop slot is full") + + slot.available_spots -= people + if slot.available_spots == 0: + slot.is_available = False + + slot.save() + + registration.status = "confirmed" + registration.save() + + +# ==================================================== +# PRODUCT CONFIRMATION +# ==================================================== + +def confirm_product_order(payment_order): + with transaction.atomic(): + order = payment_order.product_order + + if order.status == "paid": + return + + # 🔒 Lock products and reduce stock + for item in order.items.select_related("product"): + product = Product.objects.select_for_update().get(id=item.product.id) + + if product.stock < item.quantity: + raise Exception(f"{product.name} stock insufficient") + + product.stock -= item.quantity + product.save() + + # ✅ Mark order paid + order.status = "paid" + order.save() + + # ✉️ Send confirmation email + try: + send_product_email(order) + except Exception as e: + print("❌ Email failed:", e) + + + +# ==================================================== +# EMAIL SENDER +# ==================================================== + +def send_product_email(order): + html_content = render_to_string( + "emails/order_success.html", + { + "order_id": order.id, + "customer_name": order.full_name, + "order": order + } + ) + + recipient_email = order.email + + msg = EmailMultiAlternatives( + subject="Your Basho Order is Confirmed 🌿", + body="Your payment was successful.", + from_email=settings.EMAIL_HOST_USER, + to=[recipient_email], + ) + + msg.attach_alternative(html_content, "text/html") + + image_path = finders.find("care_card.png") + + if image_path: + with open(image_path, "rb") as f: + img = MIMEImage(f.read()) + img.add_header("Content-ID", "") + img.add_header("Content-Disposition", "inline", filename="care_card.png") + msg.attach(img) + + + msg.send(fail_silently=False) + + print("✅ PRODUCT EMAIL SENT TO:", recipient_email) + +def clear_user_cart(payment_order): + if not payment_order.user: + return # guest checkout → no cart to clear + + cart = Cart.objects.filter( + user=payment_order.user, + is_active=True + ).first() + + if not cart: + return + + cart.items.all().delete() + cart.is_active = False # optional but recommended + cart.save() + + print("🧹 Cart cleared for user:", payment_order.user.id) +# ==================================================== +# VERIFY PAYMENT (SINGLE SOURCE OF TRUTH) +# ==================================================== + +@csrf_exempt +@api_view(["POST"]) +def verify_payment(request): + if request.method != "POST": + return JsonResponse({"error": "POST request required"}, status=405) + + data = json.loads(request.body) + + razorpay_order_id = data.get("razorpay_order_id") + razorpay_payment_id = data.get("razorpay_payment_id") + razorpay_signature = data.get("razorpay_signature") + + try: + # 1️⃣ Verify Razorpay signature + client.utility.verify_payment_signature({ + "razorpay_order_id": razorpay_order_id, + "razorpay_payment_id": razorpay_payment_id, + "razorpay_signature": razorpay_signature + }) + + # 2️⃣ Lock & fetch payment order + with transaction.atomic(): + payment_order = PaymentOrder.objects.select_for_update().get( + razorpay_order_id=razorpay_order_id + ) + + if payment_order.status == "PAID": + return JsonResponse({"status": "already_paid"}) + + payment_order.status = "PAID" + payment_order.save() + + # 3️⃣ Create payment record + payment = Payment.objects.create( + payment_order=payment_order, + razorpay_payment_id=razorpay_payment_id, + status="PAID" + ) + + # 4️⃣ Transaction log + Transaction.objects.create( + payment=payment, + event="verified", + response=data + ) + + # 5️⃣ Post-payment business logic + if payment_order.order_type == "EXPERIENCE": + confirm_experience_booking(payment_order) + + elif payment_order.order_type == "WORKSHOP": + confirm_workshop_registration(payment_order) + + elif payment_order.order_type == "PRODUCT": + confirm_product_order(payment_order) + clear_user_cart(payment_order) + return JsonResponse({"status": "success"}) + + except Exception as e: + print("❌ PAYMENT FAILED:", str(e)) + + try: + payment_order = PaymentOrder.objects.get( + razorpay_order_id=razorpay_order_id + ) + + payment_order.status = "FAILED" + payment_order.save() + + payment = Payment.objects.create( + payment_order=payment_order, + razorpay_payment_id=razorpay_payment_id or "FAILED", + status="FAILED" + ) + + Transaction.objects.create( + payment=payment, + event="failed", + response=data + ) + except Exception: + pass + + return JsonResponse( + {"error": "Payment verification failed"}, + status=400 + ) diff --git a/basho_backend/apps/products/__init__.py b/basho_backend/apps/products/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/products/admin.py b/basho_backend/apps/products/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..9c30386cdf517ac949c2e29fe985731235243f77 --- /dev/null +++ b/basho_backend/apps/products/admin.py @@ -0,0 +1,160 @@ +from django.contrib import admin +from .models import Product, Category,CustomOrder, CustomOrderImage +from django.urls import path +from django.utils.html import format_html +from .admin_views import send_custom_order_email +from django.urls import reverse + +import os +from django.core.mail import send_mail +from django.conf import settings + + +STATUS_EMAIL_MESSAGES = { + "reviewed": "Your custom order has been reviewed by our team.", + "quoted": "Your custom order has been quoted. We will share pricing shortly.", + "approved": "Your custom order has been approved and will move to production.", + "completed": "Your custom order has been completed. Thank you for choosing us!", + "rejected": "Unfortunately, we are unable to proceed with your custom order.", +} + + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + list_display = ("name", "category", "price", "stock", "featured") + list_filter = ("category", "featured") + search_fields = ("name",) + + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + prepopulated_fields = {"slug": ("name",)} + +class CustomOrderImageInline(admin.TabularInline): + model = CustomOrderImage + extra = 0 + readonly_fields = ("image",) + + +@admin.register(CustomOrder) +class CustomOrderAdmin(admin.ModelAdmin): + list_display = ( + "id", + "name", + "email", + "email_verified", + "product_type", + "status", + "created_at", + "send_email_button", + ) + + list_filter = ("email_verified", "status", "product_type", "created_at") + search_fields = ("name", "email", "phone") + + readonly_fields = ( + "name", + "email", + "phone", + "product_type", + "quantity", + "dimensions", + "preferred_colors", + "timeline", + "budget_range", + "description", + "created_at", + "updated_at", + "email_verified", + "email_verification_token", + + ) + + fieldsets = ( + ("Customer Info", { + "fields": ("name", "email", "phone") + }), + ("Order Details", { + "fields": ( + "product_type", + "quantity", + "dimensions", + "preferred_colors", + "timeline", + "budget_range", + "description", + ) + }), + ("Management (Admin Only)", { + "fields": ("status", "admin_notes") + }), + ("Timestamps", { + "fields": ("created_at", "updated_at") + }), + ) + + inlines = [CustomOrderImageInline] + + def send_email_button(self, obj): + if not obj.email_verified: + return format_html( + '{}',"Email not verified" + ) + + return format_html( + '{}', obj.id,"Send Email" + + ) + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + "/send-email/", + self.admin_site.admin_view(send_custom_order_email), + name="send-custom-order-email", + ), + ] + return custom_urls + urls + + def save_model(self, request, obj, form, change): + if change: + old_obj = CustomOrder.objects.get(pk=obj.pk) + + if old_obj.status != obj.status: + message = STATUS_EMAIL_MESSAGES.get(obj.status) + + if message: + send_mail( + subject=f"Update on your custom order #{obj.id}", + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[obj.email], + fail_silently=False, + ) + + super().save_model(request, obj, form, change) + + def delete_reference_images(self, request, queryset): + deleted_count = 0 + + for order in queryset: + for image in order.images.all(): + image.delete() # Cloudinary handles deletion + deleted_count += 1 + + self.message_user( + request, + f"{deleted_count} reference image(s) deleted successfully." + ) + + + actions = ["delete_reference_images"] + delete_reference_images.short_description = "Delete reference images for selected custom orders" + + send_email_button.short_description = "Email Customer" + + +@admin.register(CustomOrderImage) +class CustomOrderImageAdmin(admin.ModelAdmin): + list_display = ("order", "image") \ No newline at end of file diff --git a/basho_backend/apps/products/admin_email.py b/basho_backend/apps/products/admin_email.py new file mode 100644 index 0000000000000000000000000000000000000000..4ff2493dc08aa82ce56b47a53a67714f7233f992 --- /dev/null +++ b/basho_backend/apps/products/admin_email.py @@ -0,0 +1,5 @@ +from django import forms + +class SendEmailForm(forms.Form): + subject = forms.CharField(max_length=255) + message = forms.CharField(widget=forms.Textarea) diff --git a/basho_backend/apps/products/admin_views.py b/basho_backend/apps/products/admin_views.py new file mode 100644 index 0000000000000000000000000000000000000000..d061b833a9dce5516c3b7498df56b606dbb751ef --- /dev/null +++ b/basho_backend/apps/products/admin_views.py @@ -0,0 +1,55 @@ +from django.shortcuts import get_object_or_404, redirect, render +from django.core.mail import send_mail +from django.contrib import messages +from django.conf import settings + +from .models import CustomOrder, CustomOrderEmailLog +from .admin_email import SendEmailForm +from django.urls import reverse + +def send_custom_order_email(request, order_id): + + if not request.user.is_staff: + messages.error(request, "You are not authorized.") + return redirect("/admin/") + + order = get_object_or_404(CustomOrder, id=order_id) + + if request.method == "POST": + form = SendEmailForm(request.POST) + if form.is_valid(): + subject = form.cleaned_data["subject"] + message = form.cleaned_data["message"] + + send_mail( + subject, + message, + settings.DEFAULT_FROM_EMAIL, + [order.email], + fail_silently=False, + ) + + # Log email + CustomOrderEmailLog.objects.create( + order=order, + subject=subject, + message=message, + ) + + messages.success(request, "Email sent to customer successfully.") + return redirect( + reverse("admin:products_customorder_change", args=[order.id]) + ) + + else: + form = SendEmailForm( + initial={ + "subject": f"Regarding your custom order #{order.id}", + } + ) + + return render( + request, + "admin/send_custom_order_email.html", + {"form": form, "order": order}, + ) diff --git a/basho_backend/apps/products/apps.py b/basho_backend/apps/products/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..16b5f388a5d88d95b7b0a95b36276e6458f5b984 --- /dev/null +++ b/basho_backend/apps/products/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ProductsConfig(AppConfig): + name = "apps.products" diff --git a/basho_backend/apps/products/migrations/0001_initial.py b/basho_backend/apps/products/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..bf7cf93ad6faefae6e76e310726bbf712db8047c --- /dev/null +++ b/basho_backend/apps/products/migrations/0001_initial.py @@ -0,0 +1,66 @@ +# Generated by Django 6.0 on 2026-01-07 12:01 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Category", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=50, unique=True)), + ("slug", models.SlugField(unique=True)), + ], + ), + migrations.CreateModel( + name="Product", + fields=[ + ( + "id", + models.CharField(max_length=100, primary_key=True, serialize=False), + ), + ("name", models.CharField(max_length=255)), + ("description", models.TextField()), + ("long_description", models.TextField(blank=True, null=True)), + ("price", models.PositiveIntegerField()), + ("stock", models.PositiveIntegerField()), + ("weight", models.FloatField(help_text="Weight in KG")), + ("featured", models.BooleanField(default=False)), + ("is_customizable", models.BooleanField(default=False)), + ("images", models.JSONField(default=list)), + ("available_colors", models.JSONField(default=list)), + ("features", models.JSONField(default=list)), + ("materials", models.JSONField(default=list)), + ("care_instructions", models.JSONField(default=list)), + ("dimensions", models.JSONField(blank=True, null=True)), + ("related_products", models.JSONField(blank=True, null=True)), + ("is_food_safe", models.BooleanField(default=True)), + ("is_microwave_safe", models.BooleanField(default=False)), + ("is_dishwasher_safe", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="products", + to="products.category", + ), + ), + ], + ), + ] diff --git a/basho_backend/apps/products/migrations/0002_customorder_customorderimage.py b/basho_backend/apps/products/migrations/0002_customorder_customorderimage.py new file mode 100644 index 0000000000000000000000000000000000000000..4ed54a1305a3b873832050ff87eb4b6ef592d417 --- /dev/null +++ b/basho_backend/apps/products/migrations/0002_customorder_customorderimage.py @@ -0,0 +1,78 @@ +# Generated by Django 6.0 on 2026-01-08 15:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="CustomOrder", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("email", models.EmailField(max_length=254)), + ("phone", models.CharField(blank=True, max_length=20)), + ( + "product_type", + models.CharField( + choices=[ + ("cups_mugs", "Cups & Mugs"), + ("bowls", "Bowls"), + ("plates", "Plates"), + ("vases", "Vases"), + ("decorative", "Decorative pieces"), + ("other", "Other"), + ], + max_length=50, + ), + ), + ("quantity", models.PositiveIntegerField(default=1)), + ("dimensions", models.CharField(blank=True, max_length=100)), + ("preferred_colors", models.CharField(blank=True, max_length=100)), + ("timeline", models.CharField(blank=True, max_length=100)), + ("budget_range", models.CharField(blank=True, max_length=100)), + ("description", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="CustomOrderImage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("image", models.ImageField(upload_to="custom_orders/")), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="images", + to="products.customorder", + ), + ), + ], + ), + ] diff --git a/basho_backend/apps/products/migrations/0003_customorder_admin_notes_customorder_status_and_more.py b/basho_backend/apps/products/migrations/0003_customorder_admin_notes_customorder_status_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..bb1e800745a479f43b2a915cb807b62c8321f00f --- /dev/null +++ b/basho_backend/apps/products/migrations/0003_customorder_admin_notes_customorder_status_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 6.0 on 2026-01-09 17:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0002_customorder_customorderimage"), + ] + + operations = [ + migrations.AddField( + model_name="customorder", + name="admin_notes", + field=models.TextField( + blank=True, help_text="Internal notes for admin only" + ), + ), + migrations.AddField( + model_name="customorder", + name="status", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("reviewed", "Reviewed"), + ("quoted", "Quoted"), + ("approved", "Approved"), + ("in_production", "In Production"), + ("completed", "Completed"), + ("rejected", "Rejected"), + ], + default="pending", + max_length=20, + ), + ), + migrations.AddField( + model_name="customorder", + name="updated_at", + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/basho_backend/apps/products/migrations/0004_alter_customorder_status_customorderemaillog.py b/basho_backend/apps/products/migrations/0004_alter_customorder_status_customorderemaillog.py new file mode 100644 index 0000000000000000000000000000000000000000..b54b54b5043816c61b7dee842270d7b83ae05e92 --- /dev/null +++ b/basho_backend/apps/products/migrations/0004_alter_customorder_status_customorderemaillog.py @@ -0,0 +1,54 @@ +# Generated by Django 6.0 on 2026-01-10 05:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0003_customorder_admin_notes_customorder_status_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="customorder", + name="status", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("reviewed", "Reviewed"), + ("quoted", "Quoted"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ], + default="pending", + max_length=20, + ), + ), + migrations.CreateModel( + name="CustomOrderEmailLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("subject", models.CharField(max_length=255)), + ("message", models.TextField()), + ("sent_at", models.DateTimeField(auto_now_add=True)), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="email_logs", + to="products.customorder", + ), + ), + ], + ), + ] diff --git a/basho_backend/apps/products/migrations/0005_customorder_email_verification_token_and_more.py b/basho_backend/apps/products/migrations/0005_customorder_email_verification_token_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..ad6df281d316bb924ea2bcfde5f229b1b9011f8e --- /dev/null +++ b/basho_backend/apps/products/migrations/0005_customorder_email_verification_token_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 6.0 on 2026-01-10 17:48 + +import uuid +from django.db import migrations, models + + +def generate_email_tokens(apps, schema_editor): + CustomOrder = apps.get_model("products", "CustomOrder") + for order in CustomOrder.objects.all(): + order.email_verification_token = uuid.uuid4() + order.save(update_fields=["email_verification_token"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0004_alter_customorder_status_customorderemaillog"), + ] + + operations = [ + # 1️⃣ Add token field (nullable, no unique yet) + migrations.AddField( + model_name="customorder", + name="email_verification_token", + field=models.UUIDField(null=True, editable=False), + ), + + # 2️⃣ Populate unique tokens for existing rows + migrations.RunPython(generate_email_tokens), + + # 3️⃣ Enforce uniqueness AFTER data exists + migrations.AlterField( + model_name="customorder", + name="email_verification_token", + field=models.UUIDField(unique=True, editable=False), + ), + + # 4️⃣ Add email_verified field (this one is safe) + migrations.AddField( + model_name="customorder", + name="email_verified", + field=models.BooleanField(default=False), + ), + ] diff --git a/basho_backend/apps/products/migrations/0006_alter_customorder_email_verification_token.py b/basho_backend/apps/products/migrations/0006_alter_customorder_email_verification_token.py new file mode 100644 index 0000000000000000000000000000000000000000..e29b86921c273669f9b3bffdd3442ba546eac6b1 --- /dev/null +++ b/basho_backend/apps/products/migrations/0006_alter_customorder_email_verification_token.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0 on 2026-01-10 17:56 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0005_customorder_email_verification_token_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="customorder", + name="email_verification_token", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ] diff --git a/basho_backend/apps/products/migrations/__init__.py b/basho_backend/apps/products/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/products/models.py b/basho_backend/apps/products/models.py new file mode 100644 index 0000000000000000000000000000000000000000..1f599a824f49559b9ceed5b7f071e9a15d921ce3 --- /dev/null +++ b/basho_backend/apps/products/models.py @@ -0,0 +1,143 @@ + # Create your models here. +from django.db import models +import uuid +from cloudinary.models import CloudinaryField +class Category(models.Model): + name = models.CharField(max_length=50, unique=True) # tableware, decor, custom + slug = models.SlugField(unique=True) + + def __str__(self): + return self.name + + +class Product(models.Model): + id = models.CharField( + max_length=100, + primary_key=True + ) # matches frontend slug like "ceremonial-tea-bowl-001" + + name = models.CharField(max_length=255) + category = models.ForeignKey( + Category, + on_delete=models.CASCADE, + related_name="products" + ) + + description = models.TextField() + long_description = models.TextField(blank=True, null=True) + + price = models.PositiveIntegerField() + stock = models.PositiveIntegerField() + weight = models.FloatField(help_text="Weight in KG") + + featured = models.BooleanField(default=False) + is_customizable = models.BooleanField(default=False) + + # JSON fields (PostgreSQL only) + images = models.JSONField(default=list) + available_colors = models.JSONField(default=list) + features = models.JSONField(default=list) + materials = models.JSONField(default=list) + care_instructions = models.JSONField(default=list) + dimensions = models.JSONField(blank=True, null=True) + related_products = models.JSONField(blank=True, null=True) + + is_food_safe = models.BooleanField(default=True) + is_microwave_safe = models.BooleanField(default=False) + is_dishwasher_safe = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name + +class CustomOrder(models.Model): + PRODUCT_TYPE_CHOICES = [ + ("cups_mugs", "Cups & Mugs"), + ("bowls", "Bowls"), + ("plates", "Plates"), + ("vases", "Vases"), + ("decorative", "Decorative pieces"), + ("other", "Other"), + ] + STATUS_CHOICES = [ + ("pending", "Pending"), + ("reviewed", "Reviewed"), + ("quoted", "Quoted"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ] + + + + name = models.CharField(max_length=100) + email = models.EmailField() + phone = models.CharField(max_length=20, blank=True) + + + product_type = models.CharField( + max_length=50, + choices=PRODUCT_TYPE_CHOICES + ) + + quantity = models.PositiveIntegerField(default=1) + dimensions = models.CharField(max_length=100, blank=True) + preferred_colors = models.CharField(max_length=100, blank=True) + timeline = models.CharField(max_length=100, blank=True) + budget_range = models.CharField(max_length=100, blank=True) + + description = models.TextField() + + + admin_notes = models.TextField( + blank=True, + help_text="Internal notes for admin only" + ) + + # Order management + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default="pending" + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + + class Meta: + ordering = ["-created_at"] + + email_verification_token = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False + ) + email_verified = models.BooleanField(default=False) + + def __str__(self): + return f"{self.name} - {self.product_type}" + +class CustomOrderImage(models.Model): + order = models.ForeignKey( + CustomOrder, + related_name="images", + on_delete=models.CASCADE + ) + image = CloudinaryField("image") + + def __str__(self): + return f"Image for Order {self.order.id}" + +class CustomOrderEmailLog(models.Model): + order = models.ForeignKey( + CustomOrder, + related_name="email_logs", + on_delete=models.CASCADE + ) + subject = models.CharField(max_length=255) + message = models.TextField() + sent_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Email for Order {self.order.id} at {self.sent_at}" diff --git a/basho_backend/apps/products/serializers.py b/basho_backend/apps/products/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..58330a26981212f31dca7fa20cc210ab2c4ecaf9 --- /dev/null +++ b/basho_backend/apps/products/serializers.py @@ -0,0 +1,139 @@ +from rest_framework import serializers +from .models import Product,CustomOrder,CustomOrderImage + +from django.conf import settings +from django.core.mail import send_mail +from django.urls import reverse + + +class ProductSerializer(serializers.ModelSerializer): + # Category slug for frontend + category = serializers.CharField(source="category.slug", read_only=True) + + # CamelCase mappings + longDescription = serializers.CharField( + source="long_description", required=False + ) + isCustomizable = serializers.BooleanField( + source="is_customizable" + ) + + # Boolean flags + isFoodSafe = serializers.BooleanField( + source="is_food_safe" + ) + isMicrowaveSafe = serializers.BooleanField( + source="is_microwave_safe" + ) + isDishwasherSafe = serializers.BooleanField( + source="is_dishwasher_safe" + ) + + # JSON fields + images = serializers.JSONField() + availableColors = serializers.JSONField( + source="available_colors" + ) + dimensions = serializers.JSONField( + required=False + ) + careInstructions = serializers.JSONField( + source="care_instructions" + ) + relatedProducts = serializers.JSONField( + source="related_products", required=False + ) + + class Meta: + model = Product + fields = [ + "id", + "name", + "category", + "description", + "longDescription", + "price", + "images", + "availableColors", + "features", + "dimensions", + "materials", + "careInstructions", + "isFoodSafe", + "isMicrowaveSafe", + "isDishwasherSafe", + "isCustomizable", + "stock", + "weight", + "featured", + "relatedProducts", + ] + +class CustomOrderImageSerializer(serializers.ModelSerializer): + class Meta: + model = CustomOrderImage + fields = ["id", "image"] + + +class CustomOrderSerializer(serializers.ModelSerializer): + images = CustomOrderImageSerializer(many=True, read_only=True) + + class Meta: + model = CustomOrder + fields = [ + "id", + "name", + "email", + "phone", + "product_type", + "quantity", + "dimensions", + "preferred_colors", + "timeline", + "budget_range", + "description", + "images", + "created_at", + ] + + def validate(self, attrs): + request = self.context.get("request") + files = request.FILES.getlist("reference_images") if request else [] + + # 🔴 Max image count + if len(files) > 5: + raise serializers.ValidationError( + {"reference_images": "You can upload a maximum of 5 images."} + ) + + # 🔴 File size & type + for file in files: + if file.size > 5 * 1024 * 1024: + raise serializers.ValidationError( + {"reference_images": f"{file.name} exceeds 5MB limit."} + ) + + if not file.content_type.startswith("image/"): + raise serializers.ValidationError( + {"reference_images": f"{file.name} is not a valid image."} + ) + + return attrs + + def create(self, validated_data): + request = self.context.get("request") + + # 1️⃣ Create order FIRST (NO images here) + order = CustomOrder.objects.create(**validated_data) + + # 2️⃣ Save images separately + if request and request.FILES: + for img in request.FILES.getlist("reference_images"): + CustomOrderImage.objects.create( + order=order, + image=img + ) + return order + + + diff --git a/basho_backend/apps/products/templates/admin/send_custom_order_email.html b/basho_backend/apps/products/templates/admin/send_custom_order_email.html new file mode 100644 index 0000000000000000000000000000000000000000..d938ac82bf21a645c29ab9b65b3aee0abe59848d --- /dev/null +++ b/basho_backend/apps/products/templates/admin/send_custom_order_email.html @@ -0,0 +1,14 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

Send Email to {{ order.name }}

+ +
+ {% csrf_token %} + {{ form.as_p }} + + +
+{% endblock %} diff --git a/basho_backend/apps/products/templates/email/email_verified.html b/basho_backend/apps/products/templates/email/email_verified.html new file mode 100644 index 0000000000000000000000000000000000000000..2a486b9000a015aa25c3ac8f83b49152cd06007e --- /dev/null +++ b/basho_backend/apps/products/templates/email/email_verified.html @@ -0,0 +1,169 @@ + + + + + Email Verified + + + + + + + +
+
+ +

Email Verified Successfully

+ + {% if already_verified %} +

+ Your email was already verified earlier.
+ You can safely return to our website. +

+ {% else %} +

+ Thank you for confirming your email address.
+ We’ll be in touch with you shortly regarding your custom order. +

+ {% endif %} + + + Go back to Basho by Shivangi + + + +
+ + diff --git a/basho_backend/apps/products/templates/email/verify_custom_order_email.html b/basho_backend/apps/products/templates/email/verify_custom_order_email.html new file mode 100644 index 0000000000000000000000000000000000000000..35398078214d28b8e91e9a15e4aecba9e2bc093d --- /dev/null +++ b/basho_backend/apps/products/templates/email/verify_custom_order_email.html @@ -0,0 +1,145 @@ + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+
+ 🌿 +
+ +

+ Verify Your Email +

+ +

+ Basho by Shivangi +

+
+

+ Hi {{ name }}, +

+ +

+ Thank you for placing a custom order with Basho by Shivangi. + Each piece we create is thoughtfully handcrafted, just for you. +

+ +

+ To proceed with your order, please confirm your email address by + clicking the button below. +

+ + + + +

+ Email verification helps us keep your order secure and ensures + we can update you as your piece is crafted. +

+ + +
+ +

+ If you did not request this order, you may safely ignore this email. +

+
+

+ With warmth,
+ Basho by Shivangi +

+ +

+ Handmade ceramics • Crafted with intention +

+
+ + +
+ + + diff --git a/basho_backend/apps/products/tests.py b/basho_backend/apps/products/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/basho_backend/apps/products/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/basho_backend/apps/products/urls.py b/basho_backend/apps/products/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..ffbb6f71f456da493bcd771d6d49b8a93f10f28b --- /dev/null +++ b/basho_backend/apps/products/urls.py @@ -0,0 +1,28 @@ +from django.urls import path +from .views import ( + ProductListView, + ProductDetailView, + ProductByCategoryView, + FeaturedProductListView, + CustomOrderCreateView, +) +from .admin_views import send_custom_order_email +from .views import verify_custom_order_email + + +urlpatterns = [ + # Products + path("", ProductListView.as_view(), name="product-list"), + path("featured/", FeaturedProductListView.as_view(), name="featured-products"), + path("category//", ProductByCategoryView.as_view(), name="products-by-category"), + # Custom Orders (MUST come before ) + path("custom-orders/", CustomOrderCreateView.as_view(), name="custom-order-create"), + # Single product + path( + "custom-orders/verify//", + verify_custom_order_email, + name="verify-custom-order-email", + ), + + path("/", ProductDetailView.as_view(), name="product-detail"), +] diff --git a/basho_backend/apps/products/views.py b/basho_backend/apps/products/views.py new file mode 100644 index 0000000000000000000000000000000000000000..d8b6fcfe75fc28074972b0abf3c42b2ddff1d27a --- /dev/null +++ b/basho_backend/apps/products/views.py @@ -0,0 +1,128 @@ +from django.shortcuts import render +from django.urls import reverse + +from rest_framework import generics,status +from .models import Product +from .serializers import ProductSerializer,CustomOrderSerializer +from .models import CustomOrder, CustomOrderImage + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.parsers import MultiPartParser, FormParser + +from django.shortcuts import get_object_or_404 +from django.http import HttpResponse +from .models import CustomOrder + +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.conf import settings + + +# List all products +class ProductListView(generics.ListAPIView): + queryset = Product.objects.select_related("category").all() + serializer_class = ProductSerializer + + +# Single product detail +class ProductDetailView(generics.RetrieveAPIView): + queryset = Product.objects.select_related("category").all() + serializer_class = ProductSerializer + lookup_field = "id" + + +# Products by category slug +class ProductByCategoryView(generics.ListAPIView): + serializer_class = ProductSerializer + + def get_queryset(self): + slug = self.kwargs["slug"] + return Product.objects.select_related("category").filter( + category__slug=slug + ) + + +# Featured products +class FeaturedProductListView(generics.ListAPIView): + serializer_class = ProductSerializer + + def get_queryset(self): + return Product.objects.select_related("category").filter( + featured=True + ) + +# Create custom order +class CustomOrderCreateView(generics.CreateAPIView): + queryset = CustomOrder.objects.all() + serializer_class = CustomOrderSerializer + parser_classes = [MultiPartParser, FormParser] + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + print("❌ SERIALIZER ERRORS:", serializer.errors) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + self.perform_create(serializer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get_serializer_context(self): + # ensures serializer has access to request.FILES + context = super().get_serializer_context() + context["request"] = self.request + return context + + def perform_create(self, serializer): + order = serializer.save() + + verification_link = self.request.build_absolute_uri( + reverse( + "verify-custom-order-email", + args=[str(order.email_verification_token)] + ) + ) + + html_content = render_to_string( + "email/verify_custom_order_email.html", + { + "name": order.name, + "verification_link": verification_link, + } + ) + + email = EmailMultiAlternatives( + subject="Verify your custom order – Basho by Shivangi", + body="Please verify your email to confirm your custom order.", + from_email=settings.DEFAULT_FROM_EMAIL, + to=[order.email], + ) + + email.attach_alternative(html_content, "text/html") + email.send() + +def verify_custom_order_email(request, token): + order = get_object_or_404(CustomOrder, email_verification_token=token) + + if order.email_verified: + return render( + request, + "email/email_verified.html", + { + "frontend_url": settings.FRONTEND_URL, + "already_verified": True, + } + ) + order.email_verified = True + order.save(update_fields=["email_verified"]) + + return render( + request, + "email/email_verified.html", + { + "frontend_url": settings.FRONTEND_URL, + "already_verified": False, + } + ) + + diff --git a/basho_backend/apps/reviews/__init__.py b/basho_backend/apps/reviews/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/reviews/admin.py b/basho_backend/apps/reviews/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..e171884a42d2713b16a0c65f14dd7db224c085bd --- /dev/null +++ b/basho_backend/apps/reviews/admin.py @@ -0,0 +1,25 @@ +from django.contrib import admin +from .models import Review + + +@admin.register(Review) +class ReviewAdmin(admin.ModelAdmin): + list_display = ( + "name", + "city", + "rating", + "is_approved", + "created_at", + ) + + list_filter = ("is_approved", "rating", "created_at") + search_fields = ("name", "city", "message") + + ordering = ("-created_at",) + + actions = ["approve_reviews"] + + def approve_reviews(self, request, queryset): + queryset.update(is_approved=True) + + approve_reviews.short_description = "Approve selected reviews" diff --git a/basho_backend/apps/reviews/apps.py b/basho_backend/apps/reviews/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..b3e6a19cd1cf12a06ff963012dfed8154ca714c3 --- /dev/null +++ b/basho_backend/apps/reviews/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class ReviewsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.reviews' diff --git a/basho_backend/apps/reviews/migrations/0001_initial.py b/basho_backend/apps/reviews/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..eeedea13df8ee28aa12b53f5c87c3e692109d80a --- /dev/null +++ b/basho_backend/apps/reviews/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0 on 2026-01-14 11:39 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Review', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('city', models.CharField(max_length=100)), + ('rating', models.DecimalField(decimal_places=1, max_digits=2)), + ('message', models.TextField()), + ('is_approved', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/basho_backend/apps/reviews/migrations/0002_alter_review_user.py b/basho_backend/apps/reviews/migrations/0002_alter_review_user.py new file mode 100644 index 0000000000000000000000000000000000000000..2959f1b53bb6fa32cd6649cb69bf7fa4a9e2889a --- /dev/null +++ b/basho_backend/apps/reviews/migrations/0002_alter_review_user.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0 on 2026-01-14 17:18 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reviews', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='review', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/basho_backend/apps/reviews/migrations/__init__.py b/basho_backend/apps/reviews/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/apps/reviews/models.py b/basho_backend/apps/reviews/models.py new file mode 100644 index 0000000000000000000000000000000000000000..012ba39a23c54aeea3a73f176d19d4f9fe490c27 --- /dev/null +++ b/basho_backend/apps/reviews/models.py @@ -0,0 +1,35 @@ +from django.db import models +from django.conf import settings + + +class Review(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="reviews", + null=True, + blank=True +) + + + name = models.CharField(max_length=100) + city = models.CharField(max_length=100) + + # Rating: 0.5 → 5 (half stars allowed) + rating = models.DecimalField( + max_digits=2, + decimal_places=1 + ) + + message = models.TextField() + + # Admin approval + is_approved = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.name} ({self.rating}★)" diff --git a/basho_backend/apps/reviews/tests.py b/basho_backend/apps/reviews/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/basho_backend/apps/reviews/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/basho_backend/apps/reviews/urls.py b/basho_backend/apps/reviews/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..cdedf092429fcc7a3b2654b014c519bd6ee38600 --- /dev/null +++ b/basho_backend/apps/reviews/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from .views import list_reviews, create_review + +urlpatterns = [ + path("", list_reviews, name="list_reviews"), + path("create/", create_review, name="create_review"), +] diff --git a/basho_backend/apps/reviews/views.py b/basho_backend/apps/reviews/views.py new file mode 100644 index 0000000000000000000000000000000000000000..5dfcb5c8c307f6b644dc1a6598fc8da3fe6ef30f --- /dev/null +++ b/basho_backend/apps/reviews/views.py @@ -0,0 +1,48 @@ +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.response import Response +from rest_framework import status + +from .models import Review + + +# ✅ GET approved reviews (PUBLIC) +@api_view(["GET"]) +@permission_classes([AllowAny]) +def list_reviews(request): + reviews = Review.objects.filter(is_approved=True).order_by("-created_at") + + data = [] + for r in reviews: + data.append({ + "id": r.id, + "name": r.name, + "city": r.city, + "rating": r.rating, + "message": r.message, + "created_at": r.created_at, + }) + + return Response(data, status=status.HTTP_200_OK) + + +# ✅ POST review (LOGGED-IN USERS) +@api_view(["POST"]) +@permission_classes([AllowAny]) +def create_review(request): + data = request.data + + Review.objects.create( + user=request.user if request.user.is_authenticated else None, + + name=data.get("name"), + city=data.get("city"), + rating=data.get("rating"), + message=data.get("message"), + is_approved=False, # admin must approve + ) + + return Response( + {"message": "Review submitted for approval"}, + status=status.HTTP_201_CREATED, + ) diff --git a/basho_backend/basho_backend/__init__.py b/basho_backend/basho_backend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basho_backend/basho_backend/asgi.py b/basho_backend/basho_backend/asgi.py new file mode 100644 index 0000000000000000000000000000000000000000..e61a0a3043fdae0ee64cda0ff882d1d90293ccb0 --- /dev/null +++ b/basho_backend/basho_backend/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for basho_backend project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "basho_backend.settings") + +application = get_asgi_application() diff --git a/basho_backend/basho_backend/settings.py b/basho_backend/basho_backend/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..b2be5dbc108f81b51ad4058e5f43e13eb97cbfa1 --- /dev/null +++ b/basho_backend/basho_backend/settings.py @@ -0,0 +1,330 @@ +""" +Django settings for basho_backend project. + +Generated by 'django-admin startproject' using Django 6.0. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/6.0/ref/settings/ +""" +import os + +from pathlib import Path +from dotenv import load_dotenv + + +#aish impoorts: +from datetime import timedelta + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent +# load_dotenv(BASE_DIR/".env") + +ENV_PATH = BASE_DIR / ".env" +load_dotenv(ENV_PATH) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-^u76pivvsyckfi6cph^ez5ufq7#&$3#xuygjc7h0m62^bwr=$o" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = "False" + +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +ALLOWED_HOSTS = ["*"] + +#Versal Related: +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "https://bashobyshivangi-production.up.railway.app", # ✅ Add this +] + + + + +# Application definition + +INSTALLED_APPS = [ + "jazzmin", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + + "rest_framework", + 'corsheaders', + + 'apps.experiences', + 'apps.products', + 'apps.orders', + 'apps.accounts', + "apps.corporate", + "apps.reviews", + "apps.main.apps.MainConfig", + "rest_framework.authtoken", + + "cloudinary", + "cloudinary_storage", + +] +JAZZMIN_SETTINGS = { + # Admin titles + "site_title": "Basho Admin", + "site_header": "basho", + "site_brand": "basho", + "welcome_sign": "Welcome to Basho Admin Panel", + + # Logo (THIS is the key) + "site_logo": None, + "site_logo_classes": None, + + + # Sidebar + "show_sidebar": True, + "navigation_expanded": True, + + # Theme (keep default look – good choice) + "theme": "flatly", + + # ✅ CORRECT ICON MAPPING (IMPORTANT) + "icons": { + # Accounts + "accounts.user": "fas fa-user", + "accounts.emailotp": "fas fa-envelope", + + # Authentication + "auth.group": "fas fa-users-cog", + "authtoken.token": "fas fa-key", + + # Experiences + "experiences.booking": "fas fa-calendar-check", + "experiences.experience": "fas fa-mug-hot", + "experiences.experienceslot": "fas fa-clock", + "experiences.workshop": "fas fa-paint-brush", + "experiences.workshopslot": "fas fa-hourglass", + "experiences.workshopregistration": "fas fa-users", + "experiences.StudioBooking": "fas fa-building", + "experiences.UpcomingEvent": "fas fa-calendar-alt", + + + # Orders & Payments + "orders.Cart": "fas fa-shopping-cart", + "orders.order": "fas fa-shopping-cart", + "orders.payment": "fas fa-credit-card", + "orders.transaction": "fas fa-receipt", + "orders.paymentorder": "fas fa-file-invoice", + + # Products + "products.product": "fas fa-box", + "products.category": "fas fa-tags", + "products.customorder": "fas fa-hammer", + "products.customorderimage": "fas fa-image", + + # Corporate + "corporate.corporateinquiry": "fas fa-building", + + # Reviews + "reviews.Review": "fas fa-star", + + # Auth Token icon + "authtoken.TokenProxy": "fas fa-key", + }, + + # Fallback icons + "default_icon_parents": "fas fa-folder", + "default_icon_children": "fas fa-file", + + # Custom CSS (✔ path is correct) + "custom_css": "admin/css/basho_admin.css", +} +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + + +ROOT_URLCONF = "basho_backend.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "basho_backend.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("DB_NAME"), + "USER": os.getenv("DB_USER"), + "PASSWORD": os.getenv("DB_PASSWORD"), + "HOST": os.getenv("DB_HOST"), + "PORT": os.getenv("DB_PORT"), + } +} + + + + + +# Password validation +# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/6.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" +USE_I18N = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/6.0/howto/static-files/ + +STATIC_URL = "/static/" + +STATICFILES_DIRS = [ + BASE_DIR / "static", +] + +STATIC_ROOT = BASE_DIR / "staticfiles" + +#aishwarya changes: +AUTH_USER_MODEL = "accounts.User" + +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = "smtp.gmail.com" +EMAIL_PORT = 587 +EMAIL_USE_TLS = True +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") + +CORS_ALLOW_ALL_ORIGINS = True + +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.AllowAny', + ), +} + + + + +# Upload limits (10 MB per file) +DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 +FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "AUTH_HEADER_TYPES": ("Bearer",), +} + +DEFAULT_FILE_STORAGE = "cloudinary_storage.storage.MediaCloudinaryStorage" + + +TIME_ZONE = 'Asia/Kolkata' +USE_TZ = True + +DEFAULT_FROM_EMAIL = "Basho by Shivangi " + + +FRONTEND_URL = "http://localhost:3000" + +RAZORPAY_KEY_ID = os.getenv("RAZORPAY_KEY_ID") +RAZORPAY_KEY_SECRET = os.getenv("RAZORPAY_KEY_SECRET") + +CORS_ALLOW_CREDENTIALS = True + +CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://127.0.0.1:3000", +] + +CORS_ALLOW_HEADERS = [ + "accept", + "accept-encoding", + "authorization", + "content-type", + "origin", + "user-agent", + "x-csrftoken", + "x-requested-with", +] + +CORS_ALLOW_METHODS = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", +] + +SESSION_COOKIE_SAMESITE = "Lax" +SESSION_COOKIE_SECURE = False # True only in HTTPS + + +import cloudinary +import cloudinary.uploader +import cloudinary.api + +cloudinary.config( + cloud_name=os.getenv("CLOUDINARY_CLOUD_NAME"), + api_key=os.getenv("CLOUDINARY_API_KEY"), + api_secret=os.getenv("CLOUDINARY_API_SECRET"), +) + + diff --git a/basho_backend/basho_backend/urls.py b/basho_backend/basho_backend/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..2c3a395c4b7a76996b370e6f82740ff4617808d1 --- /dev/null +++ b/basho_backend/basho_backend/urls.py @@ -0,0 +1,27 @@ +from django.contrib import admin +from django.urls import path , include +from django.conf import settings +from django.conf.urls.static import static +from rest_framework_simplejwt.views import TokenRefreshView +from apps.main.views import home + +urlpatterns = [ + path("", home, name="home"), + path("admin/", admin.site.urls), + path("api/accounts/", include("apps.accounts.urls")), + path("api/corporate/", include("apps.corporate.urls")), + path("api/experiences/", include("apps.experiences.urls")), + path("api/products/", include("apps.products.urls")), + path("api/orders/", include("apps.orders.urls")), + path("api/reviews/", include("apps.reviews.urls")), + path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + + +] + +urlpatterns += static( + settings.MEDIA_URL, + document_root=settings.MEDIA_ROOT +) + + diff --git a/basho_backend/basho_backend/wsgi.py b/basho_backend/basho_backend/wsgi.py new file mode 100644 index 0000000000000000000000000000000000000000..f080936b74a459d161acb7c8e2b5a6a07cc4afb9 --- /dev/null +++ b/basho_backend/basho_backend/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for basho_backend project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "basho_backend.settings") + +application = get_wsgi_application() diff --git a/basho_backend/manage.py b/basho_backend/manage.py new file mode 100644 index 0000000000000000000000000000000000000000..17a25949c5945cbf2e610006109fcb6de585be0f --- /dev/null +++ b/basho_backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "basho_backend.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/basho_backend/templates/emails/order_success.html b/basho_backend/templates/emails/order_success.html new file mode 100644 index 0000000000000000000000000000000000000000..bfef4aba8d856d2c49422746070b046de906d8af --- /dev/null +++ b/basho_backend/templates/emails/order_success.html @@ -0,0 +1,80 @@ + + + + + + + + + +
+ + + + + + +
+ + +

+ Thank You for Your Handmade 🌿 +

+ + +
+ + +

+ Your payment was successful. +

+ + +
+ + +

+ Order ID: {{ order_id }} +

+ + +
+ + +

+ We will start crafting and shipping your products soon. We're delighted to have you as part of our community of ceramic art lovers. Each item you purchase is thoughtfully handcrafted, making it one-of-a-kind, just like you. +

+ +
+ +

+ Your support means the world to us and helps keep the art of ceramics alive. We hope this brings warmth, beauty, and joy to your space for years to come. +

+ + +
+ + + + + + +
+ Basho by Shivangi +
+ + +
+ + +

+ – Basho by Shivangi +

+ +
+ +
+ + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..081045bb184f218fec398458f150a4458172f13b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,27 @@ +asgiref==3.11.0 +certifi==2026.1.4 +charset-normalizer==3.4.4 +cloudinary==1.44.1 +Django==6.0 +django-cloudinary-storage==0.3.0 +django-cors-headers==4.9.0 +django-jazzmin==3.0.1 +djangorestframework==3.16.1 +djangorestframework_simplejwt==5.5.1 +google-auth==2.47.0 +gunicorn==23.0.0 +idna==3.11 +packaging==25.0 +pillow==12.1.0 +psycopg2-binary==2.9.11 +pyasn1==0.6.1 +pyasn1_modules==0.4.2 +PyJWT==2.10.1 +python-dotenv==1.2.1 +razorpay==2.0.0 +requests==2.32.5 +rsa==4.9.1 +six==1.17.0 +sqlparse==0.5.5 +tzdata==2025.3 +urllib3==2.6.3