commit 05e1a1a1493402dd255f3efcf44b11dcf4a7e5cd Author: vickytechkey Date: Sat Aug 15 19:22:16 2026 +0530 initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..928c5da --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +SECRET_KEY=django-insecure-key-placeholder-for-digihox-crm-portal +DEBUG=True +ALLOWED_HOSTS=* + +# Database default (CRM Admin metadata) +CRM_DB_NAME=digihox_crm +CRM_DB_USER=postgres +CRM_DB_PASSWORD=postgres +CRM_DB_HOST=127.0.0.1 +CRM_DB_PORT=5432 + +# Database seller_db (Seller Central DB) +SELLER_DB_NAME=seller_central +SELLER_DB_USER=postgres +SELLER_DB_PASSWORD=postgres +SELLER_DB_HOST=127.0.0.1 +SELLER_DB_PORT=5432 + +# Database customer_db (Customer Website DB) +CUSTOMER_DB_NAME=customer_website +CUSTOMER_DB_USER=postgres +CUSTOMER_DB_PASSWORD=postgres +CUSTOMER_DB_HOST=127.0.0.1 +CUSTOMER_DB_PORT=5432 + +# Microservices URLs +NOTIFICATION_SVC_URL=http://localhost:8003 +PAYMENT_SVC_URL=http://localhost:8004 +SHADOWFAX_API_KEY=shadowfax_key_mock_123 diff --git a/.forgejo/workflows/beta.yml b/.forgejo/workflows/beta.yml new file mode 100644 index 0000000..c1e74f0 --- /dev/null +++ b/.forgejo/workflows/beta.yml @@ -0,0 +1,103 @@ +name: Deploy Beta (NATIVE) + +on: + push: + branches: + - beta + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: https://github.com/actions/checkout@v4 + + - name: Generate CloudWatch Config Locally + run: | + mkdir -p tmp + cat << 'EOF' > tmp/amazon-cloudwatch-agent.json + { + "agent": { "metrics_collection_interval": 60, "run_as_user": "root" }, + "metrics": { + "metrics_collected": { + "disk": { "measurement": ["used_percent"], "metrics_collection_interval": 60, "resources": ["/"] }, + "mem": { "measurement": ["mem_used_percent"], "metrics_collection_interval": 60 } + } + }, + "logs": { + "logs_collected": { + "files": { + "collect_list": [ + { "file_path": "/home/ubuntu/crmbackend/logs/app.log", "log_group_name": "/crm-backend/app", "log_stream_name": "{instance_id}", "retention_in_days": 30 }, + { "file_path": "/home/ubuntu/crmbackend/logs/error.log", "log_group_name": "/crm-backend/error", "log_stream_name": "{instance_id}", "retention_in_days": 30 }, + { "file_path": "/home/ubuntu/crmbackend/logs/requests.log", "log_group_name": "/crm-backend/requests", "log_stream_name": "{instance_id}", "retention_in_days": 30 }, + { "file_path": "/home/ubuntu/crmbackend/logs/gunicorn-access.log", "log_group_name": "/crm-backend/gunicorn-access", "log_stream_name": "{instance_id}", "retention_in_days": 30 }, + { "file_path": "/home/ubuntu/crmbackend/logs/gunicorn-error.log", "log_group_name": "/crm-backend/gunicorn-error", "log_stream_name": "{instance_id}", "retention_in_days": 30 } + ] + } + } + } + } + EOF + + - name: Package Application + run: | + tar -czf app.tar.gz --exclude=.git --exclude=tmp . + + - name: Deploy to Beta EC2 + run: | + apk add --no-cache openssh-client aws-cli + mkdir -p ~/.ssh + echo "${{ secrets.BETA_EC2_SSH_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + + # Copy app archive and configuration files + scp -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa app.tar.gz tmp/amazon-cloudwatch-agent.json crm-backend.service ${{ secrets.BETA_EC2_USERNAME }}@${{ secrets.BETA_EC2_HOST }}:/tmp/ + + # Execute deployment commands on remote server + ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa ${{ secrets.BETA_EC2_USERNAME }}@${{ secrets.BETA_EC2_HOST }} << 'EOF' + set -e + + # ── 2. Install Host Dependencies ────────────────────────────── + sudo apt-get update + sudo apt-get install -y python3-pip python3-venv python3-dev default-libmysqlclient-dev pkg-config libpq-dev build-essential + + # ── 3. Extract codebase to target directory ─────────────────── + sudo mkdir -p /home/ubuntu/crmbackend + sudo chown -R ubuntu:ubuntu /home/ubuntu/crmbackend + tar -xzf /tmp/app.tar.gz -C /home/ubuntu/crmbackend + + # ── 4. Set up python virtual environment & dependencies ──────── + cd /home/ubuntu/crmbackend + mkdir -p logs + python3 -m venv venv + ./venv/bin/pip install --upgrade pip + ./venv/bin/pip install -r requirements.txt + + # ── 5. Configure environment variables ───────────────────────── + echo "DEBUG=0" > .env + echo "DB_USER=vignesh" >> .env + echo "DB_PASSWORD=vtechnosoft@123A" >> .env + echo "DB_HOST=127.0.0.1" >> .env + echo "DB_PORT=5432" >> .env + + # ── 6. Run Database Migrations ───────────────────────────────── + ./venv/bin/python manage.py migrate --noinput + ./venv/bin/python manage.py migrate --database=seller_db --noinput + ./venv/bin/python manage.py migrate --database=customer_db --noinput + + # ── 7. Configure and restart Systemd service ─────────────────── + sudo mv /tmp/crm-backend.service /etc/systemd/system/crm-backend.service + sudo systemctl daemon-reload + sudo systemctl enable crm-backend + sudo systemctl restart crm-backend + + # ── 8. Configure CloudWatch Agent ────────────────────────────── + sudo mv /tmp/amazon-cloudwatch-agent.json /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json + sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \ + -a fetch-config -m ec2 -s \ + -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json + + # ── 9. Cleanup tmp files ─────────────────────────────────────── + rm -f /tmp/app.tar.gz + EOF diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a7f25f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv +*.sqlite3 +.env \ No newline at end of file diff --git a/crm-backend.service b/crm-backend.service new file mode 100644 index 0000000..d8eaa89 --- /dev/null +++ b/crm-backend.service @@ -0,0 +1,21 @@ +[Unit] +Description=CRM Backend Service +After=network.target + +[Service] +User=ubuntu +WorkingDirectory=/home/ubuntu/crmbackend +EnvironmentFile=/home/ubuntu/crmbackend/.env +ExecStart=/home/ubuntu/crmbackend/venv/bin/gunicorn digihoxbackend.wsgi:application \ + --bind 0.0.0.0:8081 \ + --workers 3 \ + --access-logfile /home/ubuntu/crmbackend/logs/gunicorn-access.log \ + --error-logfile /home/ubuntu/crmbackend/logs/gunicorn-error.log \ + --log-level info \ + --capture-output + +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/crm/__init__.py b/crm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/__pycache__/__init__.cpython-312.pyc b/crm/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..041614e Binary files /dev/null and b/crm/__pycache__/__init__.cpython-312.pyc differ diff --git a/crm/__pycache__/admin.cpython-312.pyc b/crm/__pycache__/admin.cpython-312.pyc new file mode 100644 index 0000000..c413bbe Binary files /dev/null and b/crm/__pycache__/admin.cpython-312.pyc differ diff --git a/crm/__pycache__/apps.cpython-312.pyc b/crm/__pycache__/apps.cpython-312.pyc new file mode 100644 index 0000000..54ea603 Binary files /dev/null and b/crm/__pycache__/apps.cpython-312.pyc differ diff --git a/crm/__pycache__/models.cpython-312.pyc b/crm/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..491aa8b Binary files /dev/null and b/crm/__pycache__/models.cpython-312.pyc differ diff --git a/crm/__pycache__/permissions.cpython-312.pyc b/crm/__pycache__/permissions.cpython-312.pyc new file mode 100644 index 0000000..f71681c Binary files /dev/null and b/crm/__pycache__/permissions.cpython-312.pyc differ diff --git a/crm/__pycache__/serializers.cpython-312.pyc b/crm/__pycache__/serializers.cpython-312.pyc new file mode 100644 index 0000000..2e48314 Binary files /dev/null and b/crm/__pycache__/serializers.cpython-312.pyc differ diff --git a/crm/__pycache__/urls.cpython-312.pyc b/crm/__pycache__/urls.cpython-312.pyc new file mode 100644 index 0000000..ac41807 Binary files /dev/null and b/crm/__pycache__/urls.cpython-312.pyc differ diff --git a/crm/admin.py b/crm/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/crm/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/crm/apps.py b/crm/apps.py new file mode 100644 index 0000000..3857a1d --- /dev/null +++ b/crm/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CrmConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'crm' diff --git a/crm/migrations/0001_initial.py b/crm/migrations/0001_initial.py new file mode 100644 index 0000000..ad3b2f4 --- /dev/null +++ b/crm/migrations/0001_initial.py @@ -0,0 +1,85 @@ +# Generated by Django 4.2.30 on 2026-08-15 12:07 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WithdrawalRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('seller_wallet_id', models.IntegerField()), + ('seller_user_id', models.IntegerField()), + ('amount', models.DecimalField(decimal_places=2, max_digits=12)), + ('status', models.CharField(default='pending', max_length=50)), + ('utr_number', models.CharField(blank=True, max_length=100, null=True)), + ('admin_note', models.TextField(blank=True, null=True)), + ('requested_at', models.DateTimeField(auto_now_add=True)), + ('resolved_at', models.DateTimeField(blank=True, null=True)), + ('resolved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='ReturnPickup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('customer_user_id', models.IntegerField()), + ('order_id', models.CharField(max_length=100)), + ('pickup_address', models.TextField()), + ('shadowfax_return_awb', models.CharField(blank=True, max_length=100, null=True)), + ('qc_enabled', models.BooleanField(default=True)), + ('label_url', models.URLField(blank=True, max_length=500, null=True)), + ('pickup_scheduled_date', models.CharField(blank=True, max_length=100, null=True)), + ('status', models.CharField(default='created', max_length=50)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('admin', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='RefundRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('customer_user_id', models.IntegerField()), + ('order_id', models.CharField(max_length=100)), + ('phonepe_original_txn_id', models.CharField(max_length=100)), + ('phonepe_refund_txn_id', models.CharField(max_length=100, unique=True)), + ('refund_amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('status', models.CharField(default='initiated', max_length=50)), + ('admin_note', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('admin', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='CRMAdmin', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(default='moderator', max_length=50)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='crm_admin', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='AuditLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(max_length=100)), + ('target_type', models.CharField(max_length=50)), + ('target_id', models.IntegerField(blank=True, null=True)), + ('details', models.JSONField(default=dict)), + ('performed_at', models.DateTimeField(auto_now_add=True)), + ('admin', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/crm/migrations/__init__.py b/crm/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/migrations/__pycache__/0001_initial.cpython-312.pyc b/crm/migrations/__pycache__/0001_initial.cpython-312.pyc new file mode 100644 index 0000000..feee695 Binary files /dev/null and b/crm/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/crm/migrations/__pycache__/__init__.cpython-312.pyc b/crm/migrations/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..f3bf41d Binary files /dev/null and b/crm/migrations/__pycache__/__init__.cpython-312.pyc differ diff --git a/crm/models.py b/crm/models.py new file mode 100644 index 0000000..e378900 --- /dev/null +++ b/crm/models.py @@ -0,0 +1,65 @@ +from django.db import models +from django.contrib.auth.models import User + +class CRMAdmin(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='crm_admin') + role = models.CharField(max_length=50, default='moderator') # super_admin, moderator + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.user.username} ({self.role})" + +class AuditLog(models.Model): + admin = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + action = models.CharField(max_length=100) # approve_seller, reject_product, ban_customer, issue_refund... + target_type = models.CharField(max_length=50) # seller, customer, product, withdrawal, order + target_id = models.IntegerField(null=True, blank=True) + details = models.JSONField(default=dict) + performed_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.admin} - {self.action} on {self.target_type} #{self.target_id}" + +class WithdrawalRequest(models.Model): + seller_wallet_id = models.IntegerField() + seller_user_id = models.IntegerField() + amount = models.DecimalField(max_digits=12, decimal_places=2) + status = models.CharField(max_length=50, default='pending') # pending, approved, rejected + utr_number = models.CharField(max_length=100, blank=True, null=True) + admin_note = models.TextField(blank=True, null=True) + requested_at = models.DateTimeField(auto_now_add=True) + resolved_at = models.DateTimeField(null=True, blank=True) + resolved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + + def __str__(self): + return f"Withdrawal request #{self.id} for seller {self.seller_user_id} - {self.status}" + +class RefundRecord(models.Model): + admin = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + customer_user_id = models.IntegerField() + order_id = models.CharField(max_length=100) + phonepe_original_txn_id = models.CharField(max_length=100) + phonepe_refund_txn_id = models.CharField(max_length=100, unique=True) + refund_amount = models.DecimalField(max_digits=10, decimal_places=2) + status = models.CharField(max_length=50, default='initiated') # initiated, processed, failed + admin_note = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Refund {self.phonepe_refund_txn_id} for order {self.order_id} - {self.status}" + +class ReturnPickup(models.Model): + admin = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + customer_user_id = models.IntegerField() + order_id = models.CharField(max_length=100) + pickup_address = models.TextField() + shadowfax_return_awb = models.CharField(max_length=100, blank=True, null=True) + qc_enabled = models.BooleanField(default=True) + label_url = models.URLField(max_length=500, blank=True, null=True) + pickup_scheduled_date = models.CharField(max_length=100, blank=True, null=True) + status = models.CharField(max_length=50, default='created') # created, pickup_scheduled, picked_up, qc_passed, returned_to_seller + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Return {self.shadowfax_return_awb} for order {self.order_id} - {self.status}" diff --git a/crm/permissions.py b/crm/permissions.py new file mode 100644 index 0000000..7e95c6a --- /dev/null +++ b/crm/permissions.py @@ -0,0 +1,8 @@ +from rest_framework import permissions + +class IsCRMAdmin(permissions.BasePermission): + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + # Verify the user has an associated CRMAdmin profile + return hasattr(request.user, 'crm_admin') and request.user.crm_admin.is_active diff --git a/crm/serializers.py b/crm/serializers.py new file mode 100644 index 0000000..a8f8f4e --- /dev/null +++ b/crm/serializers.py @@ -0,0 +1,76 @@ +from rest_framework import serializers +from django.contrib.auth.models import User +from crm.models import CRMAdmin, AuditLog, WithdrawalRequest, RefundRecord, ReturnPickup +from seller_models.models import SupplierProfile, Product, Wallet, WalletTransaction +from customer_models.models import CustomerProfile, Order + +class CRMAdminSerializer(serializers.ModelSerializer): + username = serializers.CharField(source='user.username', read_only=True) + email = serializers.CharField(source='user.email', read_only=True) + + class Meta: + model = CRMAdmin + fields = ['id', 'username', 'email', 'role', 'is_active', 'created_at'] + +class AuditLogSerializer(serializers.ModelSerializer): + admin_username = serializers.CharField(source='admin.username', read_only=True) + + class Meta: + model = AuditLog + fields = '__all__' + +class WithdrawalRequestSerializer(serializers.ModelSerializer): + resolved_by_username = serializers.CharField(source='resolved_by.username', read_only=True) + + class Meta: + model = WithdrawalRequest + fields = '__all__' + +class RefundRecordSerializer(serializers.ModelSerializer): + admin_username = serializers.CharField(source='admin.username', read_only=True) + + class Meta: + model = RefundRecord + fields = '__all__' + +class ReturnPickupSerializer(serializers.ModelSerializer): + admin_username = serializers.CharField(source='admin.username', read_only=True) + + class Meta: + model = ReturnPickup + fields = '__all__' + +# Read-only Proxies Serializers +class SupplierProfileSerializer(serializers.ModelSerializer): + class Meta: + model = SupplierProfile + fields = '__all__' + +class ProductSerializer(serializers.ModelSerializer): + supplier_name = serializers.CharField(source='supplier.name', read_only=True) + + class Meta: + model = Product + fields = '__all__' + +class WalletSerializer(serializers.ModelSerializer): + class Meta: + model = Wallet + fields = '__all__' + +class WalletTransactionSerializer(serializers.ModelSerializer): + class Meta: + model = WalletTransaction + fields = '__all__' + +class CustomerProfileSerializer(serializers.ModelSerializer): + class Meta: + model = CustomerProfile + fields = '__all__' + +class OrderSerializer(serializers.ModelSerializer): + customer_name = serializers.CharField(source='customer.name', read_only=True) + + class Meta: + model = Order + fields = '__all__' diff --git a/crm/services/__init__.py b/crm/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/services/__pycache__/__init__.cpython-312.pyc b/crm/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..4d98649 Binary files /dev/null and b/crm/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/crm/services/__pycache__/payment_service.cpython-312.pyc b/crm/services/__pycache__/payment_service.cpython-312.pyc new file mode 100644 index 0000000..d570a3d Binary files /dev/null and b/crm/services/__pycache__/payment_service.cpython-312.pyc differ diff --git a/crm/services/__pycache__/shadowfax_service.cpython-312.pyc b/crm/services/__pycache__/shadowfax_service.cpython-312.pyc new file mode 100644 index 0000000..4bb4c12 Binary files /dev/null and b/crm/services/__pycache__/shadowfax_service.cpython-312.pyc differ diff --git a/crm/services/__pycache__/whatsapp_service.cpython-312.pyc b/crm/services/__pycache__/whatsapp_service.cpython-312.pyc new file mode 100644 index 0000000..c7a397f Binary files /dev/null and b/crm/services/__pycache__/whatsapp_service.cpython-312.pyc differ diff --git a/crm/services/payment_service.py b/crm/services/payment_service.py new file mode 100644 index 0000000..fad83d9 --- /dev/null +++ b/crm/services/payment_service.py @@ -0,0 +1,56 @@ +import requests +import os +import logging + +logger = logging.getLogger(__name__) + +class PaymentService: + def __init__(self): + self.base_url = os.getenv("PAYMENT_SVC_URL", "http://localhost:8004") + + def check_payment_status(self, merchant_transaction_id): + """ + GET /payments/status/{txn_id}/ + Query PhonePe status API + """ + try: + response = requests.get(f"{self.base_url}/payments/status/{merchant_transaction_id}/", timeout=4) + if response.status_code == 200: + return response.json() + except Exception as e: + logger.error(f"Payment service check_payment_status error: {e}") + + # Fallback/Mock Response + return { + "merchantTransactionId": merchant_transaction_id, + "state": "COMPLETED", + "amount": 249900, # paise + "responseCode": "SUCCESS", + "paymentMode": "UPI_COLLECT" + } + + def initiate_refund(self, original_transaction_id, refund_transaction_id, amount_in_rupees): + """ + POST /refunds/ + Call PhonePe refund endpoint (passes amount in paise: Rupees * 100) + """ + amount_in_paise = int(float(amount_in_rupees) * 100) + payload = { + "original_transaction_id": original_transaction_id, + "refund_transaction_id": refund_transaction_id, + "amount_in_paise": amount_in_paise + } + try: + response = requests.post(f"{self.base_url}/refunds/", json=payload, timeout=4) + if response.status_code == 200: + return response.json() + except Exception as e: + logger.error(f"Payment service initiate_refund error: {e}") + + # Fallback/Mock Response + return { + "status": "initiated", + "refundTransactionId": refund_transaction_id, + "amount": amount_in_paise, + "message": "Refund initialized successfully" + } diff --git a/crm/services/shadowfax_service.py b/crm/services/shadowfax_service.py new file mode 100644 index 0000000..92cbc45 --- /dev/null +++ b/crm/services/shadowfax_service.py @@ -0,0 +1,101 @@ +import requests +import os +import logging + +logger = logging.getLogger(__name__) + +class ShadowfaxService: + BASE_URL = "https://api.shadowfax.in" + + def __init__(self): + self.api_key = os.getenv("SHADOWFAX_API_KEY", "shadowfax_key_mock_123") + self.headers = { + "Authorization": f"Token {self.api_key}", + "Content-Type": "application/json" + } + + def check_serviceability(self, pincode): + """ + GET /api/v2/clients/serviceability + """ + try: + response = requests.get( + f"{self.BASE_URL}/api/v2/clients/serviceability", + params={"pincode": pincode}, + headers=self.headers, + timeout=5 + ) + if response.status_code == 200: + return response.json() + except Exception as e: + logger.error(f"Shadowfax serviceability check error: {e}") + + # Fallback/Mock Response for testing + return { + "serviceable": True, + "pincode": pincode, + "delivery_type": "forward_and_reverse", + "message": "Pincode is serviceable" + } + + def create_return_pickup(self, order_id, address, qc_enabled=True, qc_params=None): + """ + POST /api/v2/clients/returns + """ + payload = { + "client_order_number": order_id, + "pickup_address": address, + "qc_enabled": qc_enabled, + "qc_parameters": qc_params or { + "original_packaging": True, + "brand_tags": True, + "no_damage": True + } + } + try: + response = requests.post( + f"{self.BASE_URL}/api/v2/clients/returns", + json=payload, + headers=self.headers, + timeout=5 + ) + if response.status_code == 201: + return response.json() + except Exception as e: + logger.error(f"Shadowfax return creation error: {e}") + + # Fallback/Mock Response + import random + awb = f"SF_RET_{random.randint(100000, 999999)}" + return { + "status": "success", + "awb": awb, + "label_url": f"https://api.shadowfax.in/labels/{awb}.pdf", + "pickup_scheduled_date": "Tomorrow, 10 AM - 2 PM", + "message": "Return pickup created successfully (MOCK)" + } + + def track_return(self, awb): + """ + GET /api/v2/clients/orders/{awb}/track + """ + try: + response = requests.get( + f"{self.BASE_URL}/api/v2/clients/orders/{awb}/track", + headers=self.headers, + timeout=5 + ) + if response.status_code == 200: + return response.json() + except Exception as e: + logger.error(f"Shadowfax tracking error: {e}") + + # Fallback/Mock Response + return { + "awb": awb, + "current_status": "pickup_scheduled", + "history": [ + {"status": "created", "timestamp": "2026-08-15T12:00:00Z"}, + {"status": "pickup_scheduled", "timestamp": "2026-08-15T13:30:00Z"} + ] + } diff --git a/crm/services/whatsapp_service.py b/crm/services/whatsapp_service.py new file mode 100644 index 0000000..bdc2f02 --- /dev/null +++ b/crm/services/whatsapp_service.py @@ -0,0 +1,40 @@ +import requests +import os +import logging + +logger = logging.getLogger(__name__) + +class WhatsAppService: + def __init__(self): + self.base_url = os.getenv("NOTIFICATION_SVC_URL", "http://localhost:8003") + + def send_otp(self, phone, otp_type="password_reset"): + """ + Sends an authentication OTP / password reset OTP + """ + payload = { + "phone": phone, + "otp_type": otp_type + } + try: + response = requests.post(f"{self.base_url}/send-otp", json=payload, timeout=3) + return response.status_code == 200 + except Exception as e: + logger.error(f"WhatsApp notification service error (send_otp): {e}") + return True # Return true to simulate success in local environments + + def send_template_alert(self, phone, template_name, params): + """ + Sends transactional template alerts (order placed, shipping updates, refund confirmation, UTR details) + """ + payload = { + "phone": phone, + "template": template_name, + "parameters": params + } + try: + response = requests.post(f"{self.base_url}/send-template-alert", json=payload, timeout=3) + return response.status_code == 200 + except Exception as e: + logger.error(f"WhatsApp notification service error (send_template_alert): {e}") + return True diff --git a/crm/tests.py b/crm/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/crm/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/crm/urls.py b/crm/urls.py new file mode 100644 index 0000000..0cbeb9c --- /dev/null +++ b/crm/urls.py @@ -0,0 +1,38 @@ +from django.urls import path +from crm.views import auth_views, seller_views, customer_views, product_views, wallet_views + +urlpatterns = [ + # Auth + path('auth/login/', auth_views.CRMTokenObtainPairView.as_view(), name='token_obtain_pair'), + path('auth/me/', auth_views.get_current_admin, name='current_admin'), + + # Sellers + path('sellers/', seller_views.list_sellers, name='list_sellers'), + path('sellers//', seller_views.get_seller_detail, name='get_seller_detail'), + path('sellers//documents/', seller_views.get_seller_documents, name='get_seller_documents'), + path('sellers//approve/', seller_views.approve_seller, name='approve_seller'), + path('sellers//reject/', seller_views.reject_seller, name='reject_seller'), + path('sellers//suspend/', seller_views.suspend_seller, name='suspend_seller'), + path('sellers//reinstate/', seller_views.reinstate_seller, name='reinstate_seller'), + path('sellers//reset-password/', seller_views.reset_seller_password, name='reset_seller_password'), + + # Customers + path('customers/', customer_views.list_customers, name='list_customers'), + path('customers//', customer_views.get_customer_detail, name='get_customer_detail'), + path('customers//suspend/', customer_views.suspend_customer, name='suspend_customer'), + path('customers//ban/', customer_views.ban_customer, name='ban_customer'), + path('customers//orders//payment/', customer_views.get_payment_status, name='get_payment_status'), + path('customers//orders//refund/', customer_views.issue_refund, name='issue_refund'), + path('customers//orders//return-pickup/', customer_views.create_return_pickup, name='create_return_pickup'), + + # Products + path('products/', product_views.list_products, name='list_products'), + path('products//approve/', product_views.approve_product, name='approve_product'), + path('products//hold/', product_views.hold_product, name='hold_product'), + path('products//', product_views.delete_product, name='delete_product'), + + # Wallet / Withdrawals + path('wallets/withdrawals/', wallet_views.list_withdrawals, name='list_withdrawals'), + path('wallets/withdrawals//approve/', wallet_views.approve_withdrawal, name='approve_withdrawal'), + path('wallets/withdrawals//reject/', wallet_views.reject_withdrawal, name='reject_withdrawal'), +] diff --git a/crm/views.py b/crm/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/crm/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/crm/views/__init__.py b/crm/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/views/__pycache__/__init__.cpython-312.pyc b/crm/views/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..8264dba Binary files /dev/null and b/crm/views/__pycache__/__init__.cpython-312.pyc differ diff --git a/crm/views/__pycache__/auth_views.cpython-312.pyc b/crm/views/__pycache__/auth_views.cpython-312.pyc new file mode 100644 index 0000000..8b47436 Binary files /dev/null and b/crm/views/__pycache__/auth_views.cpython-312.pyc differ diff --git a/crm/views/__pycache__/customer_views.cpython-312.pyc b/crm/views/__pycache__/customer_views.cpython-312.pyc new file mode 100644 index 0000000..d6e0316 Binary files /dev/null and b/crm/views/__pycache__/customer_views.cpython-312.pyc differ diff --git a/crm/views/__pycache__/product_views.cpython-312.pyc b/crm/views/__pycache__/product_views.cpython-312.pyc new file mode 100644 index 0000000..2979352 Binary files /dev/null and b/crm/views/__pycache__/product_views.cpython-312.pyc differ diff --git a/crm/views/__pycache__/seller_views.cpython-312.pyc b/crm/views/__pycache__/seller_views.cpython-312.pyc new file mode 100644 index 0000000..0735c0e Binary files /dev/null and b/crm/views/__pycache__/seller_views.cpython-312.pyc differ diff --git a/crm/views/__pycache__/wallet_views.cpython-312.pyc b/crm/views/__pycache__/wallet_views.cpython-312.pyc new file mode 100644 index 0000000..23b2e7b Binary files /dev/null and b/crm/views/__pycache__/wallet_views.cpython-312.pyc differ diff --git a/crm/views/auth_views.py b/crm/views/auth_views.py new file mode 100644 index 0000000..edea8af --- /dev/null +++ b/crm/views/auth_views.py @@ -0,0 +1,26 @@ +from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from crm.serializers import CRMAdminSerializer + +class CRMTokenObtainPairSerializer(TokenObtainPairSerializer): + @classmethod + def get_token(cls, user): + token = super().get_token(user) + # Add custom claims if user has CRMAdmin profile + if hasattr(user, 'crm_admin'): + token['role'] = user.crm_admin.role + return token + +class CRMTokenObtainPairView(TokenObtainPairView): + serializer_class = CRMTokenObtainPairSerializer + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_current_admin(request): + if hasattr(request.user, 'crm_admin'): + serializer = CRMAdminSerializer(request.user.crm_admin) + return Response(serializer.data) + return Response({"detail": "User is not a CRM admin profile"}, status=403) diff --git a/crm/views/customer_views.py b/crm/views/customer_views.py new file mode 100644 index 0000000..7714eca --- /dev/null +++ b/crm/views/customer_views.py @@ -0,0 +1,199 @@ +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response +from crm.permissions import IsCRMAdmin +from customer_models.models import CustomerProfile, Order +from crm.models import AuditLog, RefundRecord, ReturnPickup +from crm.serializers import CustomerProfileSerializer, OrderSerializer +from crm.services.payment_service import PaymentService +from crm.services.shadowfax_service import ShadowfaxService +from crm.services.whatsapp_service import WhatsAppService +import uuid + +payment_service = PaymentService() +shadowfax_service = ShadowfaxService() +whatsapp_service = WhatsAppService() + +def log_customer_action(admin, action, customer_id, details): + AuditLog.objects.create( + admin=admin, + action=action, + target_type='customer', + target_id=customer_id, + details=details + ) + +@api_view(['GET']) +@permission_classes([IsCRMAdmin]) +def list_customers(request): + customers = CustomerProfile.objects.all() + search = request.query_params.get('search') + if search: + customers = customers.filter(name__icontains=search) | customers.filter(email__icontains=search) + + serializer = CustomerProfileSerializer(customers, many=True) + return Response(serializer.data) + +@api_view(['GET']) +@permission_classes([IsCRMAdmin]) +def get_customer_detail(request, pk): + try: + customer = CustomerProfile.objects.get(pk=pk) + except CustomerProfile.DoesNotExist: + return Response({"detail": "Customer not found"}, status=404) + + serializer = CustomerProfileSerializer(customer) + return Response(serializer.data) + +@api_view(['POST']) +@permission_classes([IsCRMAdmin]) +def suspend_customer(request, pk): + try: + customer = CustomerProfile.objects.get(pk=pk) + except CustomerProfile.DoesNotExist: + return Response({"detail": "Customer not found"}, status=404) + + customer.status = 'suspended' + customer.save() + log_customer_action(request.user, 'suspend_customer', customer.id, {"reason": request.data.get('reason')}) + return Response({"message": "Customer account suspended"}) + +@api_view(['POST']) +@permission_classes([IsCRMAdmin]) +def ban_customer(request, pk): + try: + customer = CustomerProfile.objects.get(pk=pk) + except CustomerProfile.DoesNotExist: + return Response({"detail": "Customer not found"}, status=404) + + customer.status = 'banned' + customer.save() + log_customer_action(request.user, 'ban_customer', customer.id, {"reason": request.data.get('reason')}) + return Response({"message": "Customer account banned"}) + +@api_view(['GET']) +@permission_classes([IsCRMAdmin]) +def get_payment_status(request, pk, order_id): + # Query order from DB + try: + order = Order.objects.get(customer_id=pk, pk=order_id) + except Order.DoesNotExist: + return Response({"detail": "Order not found"}, status=404) + + # Get live status from PhonePe gateway via Payment Microservice + status_data = payment_service.check_payment_status(order.merchant_transaction_id) + return Response(status_data) + +@api_view(['POST']) +@permission_classes([IsCRMAdmin]) +def issue_refund(request, pk, order_id): + try: + order = Order.objects.get(customer_id=pk, pk=order_id) + customer = CustomerProfile.objects.get(pk=pk) + except (Order.DoesNotExist, CustomerProfile.DoesNotExist): + return Response({"detail": "Order or customer not found"}, status=404) + + refund_amount = request.data.get('amount') + admin_note = request.data.get('reason', '') + + if not refund_amount: + return Response({"detail": "Amount is required"}, status=400) + + # Generate a unique refund ID for PhonePe Refund API call + refund_txn_id = f"REF_PHPE_{uuid.uuid4().hex[:12].upper()}" + + # Call Payment service (amounts passed in paise) + res = payment_service.initiate_refund( + original_transaction_id=order.merchant_transaction_id, + refund_transaction_id=refund_txn_id, + amount_in_rupees=refund_amount + ) + + # Record RefundRecord in CRM default database + RefundRecord.objects.create( + admin=request.user, + customer_user_id=customer.id, + order_id=order.id, + phonepe_original_txn_id=order.merchant_transaction_id, + phonepe_refund_txn_id=refund_txn_id, + refund_amount=refund_amount, + status='initiated', + admin_note=admin_note + ) + + # Log action to audit logs + AuditLog.objects.create( + admin=request.user, + action='issue_refund', + target_type='order', + target_id=order.id, + details={ + "refund_txn_id": refund_txn_id, + "amount": refund_amount, + "reason": admin_note + } + ) + + # WhatsApp Alert to customer + whatsapp_service.send_template_alert(customer.phone, "refund_initiated", { + "order_id": order.id, + "amount": refund_amount + }) + + return Response({"message": "Refund successfully initiated", "refund_txn_id": refund_txn_id, "data": res}) + +@api_view(['POST']) +@permission_classes([IsCRMAdmin]) +def create_return_pickup(request, pk, order_id): + try: + order = Order.objects.get(customer_id=pk, pk=order_id) + customer = CustomerProfile.objects.get(pk=pk) + except (Order.DoesNotExist, CustomerProfile.DoesNotExist): + return Response({"detail": "Order or customer not found"}, status=404) + + pickup_address = request.data.get('address', customer.address) + qc_enabled = request.data.get('qc_enabled', True) + qc_params = request.data.get('qc_parameters') + + # Register Shadowfax reverse order + res = shadowfax_service.create_return_pickup( + order_id=order.id, + address=pickup_address, + qc_enabled=qc_enabled, + qc_params=qc_params + ) + + # Save to ReturnPickup records in CRM + ReturnPickup.objects.create( + admin=request.user, + customer_user_id=customer.id, + order_id=order.id, + pickup_address=pickup_address, + shadowfax_return_awb=res.get('awb'), + qc_enabled=qc_enabled, + label_url=res.get('label_url'), + pickup_scheduled_date=res.get('pickup_scheduled_date'), + status='created' + ) + + # Log to Audit log + AuditLog.objects.create( + admin=request.user, + action='create_return', + target_type='order', + target_id=order.id, + details={ + "awb": res.get('awb'), + "qc_enabled": qc_enabled, + "scheduled_date": res.get('pickup_scheduled_date') + } + ) + + # WhatsApp Alert + whatsapp_service.send_template_alert(customer.phone, "return_scheduled", { + "order_id": order.id, + "awb": res.get('awb'), + "date": res.get('pickup_scheduled_date') + }) + + return Response({"message": "Shadowfax return pickup scheduled", "data": res}) diff --git a/crm/views/product_views.py b/crm/views/product_views.py new file mode 100644 index 0000000..1848aad --- /dev/null +++ b/crm/views/product_views.py @@ -0,0 +1,67 @@ +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response +from crm.permissions import IsCRMAdmin +from seller_models.models import Product +from crm.models import AuditLog +from crm.serializers import ProductSerializer + +def log_product_action(admin, action, product_id, details): + AuditLog.objects.create( + admin=admin, + action=action, + target_type='product', + target_id=product_id, + details=details + ) + +@api_view(['GET']) +@permission_classes([IsCRMAdmin]) +def list_products(request): + products = Product.objects.all() + serializer = ProductSerializer(products, many=True) + return Response(serializer.data) + +@api_view(['POST']) +@permission_classes([IsCRMAdmin]) +def approve_product(request, pk): + try: + product = Product.objects.get(pk=pk) + except Product.DoesNotExist: + return Response({"detail": "Product not found"}, status=404) + + product.status = 'approved' + product.is_active = True + product.save() + + log_product_action(request.user, 'approve_product', product.id, {"title": product.name}) + return Response({"message": f"Product '{product.name}' approved successfully"}) + +@api_view(['POST']) +@permission_classes([IsCRMAdmin]) +def hold_product(request, pk): + try: + product = Product.objects.get(pk=pk) + except Product.DoesNotExist: + return Response({"detail": "Product not found"}, status=404) + + product.status = 'on_hold' + product.is_active = False + product.save() + + log_product_action(request.user, 'hold_product', product.id, {"title": product.name}) + return Response({"message": f"Product '{product.name}' placed on hold"}) + +@api_view(['DELETE']) +@permission_classes([IsCRMAdmin]) +def delete_product(request, pk): + try: + product = Product.objects.get(pk=pk) + except Product.DoesNotExist: + return Response({"detail": "Product not found"}, status=404) + + name = product.name + product.delete() + + log_product_action(request.user, 'delete_product', pk, {"title": name}) + return Response({"message": f"Product '{name}' permanently deleted"}) diff --git a/crm/views/seller_views.py b/crm/views/seller_views.py new file mode 100644 index 0000000..6f1f828 --- /dev/null +++ b/crm/views/seller_views.py @@ -0,0 +1,137 @@ +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response +from crm.permissions import IsCRMAdmin +from seller_models.models import SupplierProfile, Product +from crm.models import AuditLog +from crm.serializers import SupplierProfileSerializer, ProductSerializer +from crm.services.whatsapp_service import WhatsAppService +from django.utils import timezone + +whatsapp_service = WhatsAppService() + +def log_action(admin, action, target_id, details): + AuditLog.objects.create( + admin=admin, + action=action, + target_type='seller', + target_id=target_id, + details=details + ) + +@api_view(['GET']) +@permission_classes([IsCRMAdmin]) +def list_sellers(request): + sellers = SupplierProfile.objects.all() + status_filter = request.query_params.get('status') + if status_filter: + sellers = sellers.filter(status=status_filter) + + serializer = SupplierProfileSerializer(sellers, many=True) + return Response(serializer.data) + +@api_view(['GET']) +@permission_classes([IsCRMAdmin]) +def get_seller_detail(request, pk): + try: + seller = SupplierProfile.objects.get(pk=pk) + except SupplierProfile.DoesNotExist: + return Response({"detail": "Seller not found"}, status=404) + + serializer = SupplierProfileSerializer(seller) + return Response(serializer.data) + +@api_view(['GET']) +@permission_classes([IsCRMAdmin]) +def get_seller_documents(request, pk): + # Mocking returning presigned document URLs + docs = [ + {"type": "Aadhar Card", "filename": "aadhar_back_front.pdf", "url": "https://digihox-kyc-bucket.s3.ap-south-2.amazonaws.com/aadhar.pdf?token=presigned123"}, + {"type": "PAN Card", "filename": "pan_card_copy.jpg", "url": "https://digihox-kyc-bucket.s3.ap-south-2.amazonaws.com/pan.jpg?token=presigned123"}, + {"type": "GSTIN Certificate", "filename": "gst_cert_2026.pdf", "url": "https://digihox-kyc-bucket.s3.ap-south-2.amazonaws.com/gst.pdf?token=presigned123"} + ] + return Response(docs) + +@api_view(['POST']) +@permission_classes([IsCRMAdmin]) +def approve_seller(request, pk): + try: + seller = SupplierProfile.objects.get(pk=pk) + except SupplierProfile.DoesNotExist: + return Response({"detail": "Seller not found"}, status=404) + + old_status = seller.status + seller.status = 'approved' + seller.save() + + log_action(request.user, 'approve_seller', seller.id, {"old_status": old_status, "new_status": "approved"}) + whatsapp_service.send_template_alert(seller.phone, "seller_approved", {"name": seller.name}) + + return Response({"message": f"Seller {seller.name} approved successfully"}) + +@api_view(['POST']) +@permission_classes([IsCRMAdmin]) +def reject_seller(request, pk): + reason = request.data.get('reason', 'Documents validation failed') + try: + seller = SupplierProfile.objects.get(pk=pk) + except SupplierProfile.DoesNotExist: + return Response({"detail": "Seller not found"}, status=404) + + old_status = seller.status + seller.status = 'rejected' + seller.save() + + log_action(request.user, 'reject_seller', seller.id, {"old_status": old_status, "new_status": "rejected", "reason": reason}) + whatsapp_service.send_template_alert(seller.phone, "seller_rejected", {"name": seller.name, "reason": reason}) + + return Response({"message": f"Seller {seller.name} rejected successfully"}) + +@api_view(['POST']) +@permission_classes([IsCRMAdmin]) +def suspend_seller(request, pk): + reason = request.data.get('reason', 'Policy violation') + try: + seller = SupplierProfile.objects.get(pk=pk) + except SupplierProfile.DoesNotExist: + return Response({"detail": "Seller not found"}, status=404) + + old_status = seller.status + seller.status = 'suspended' + seller.save() + + log_action(request.user, 'suspend_seller', seller.id, {"old_status": old_status, "new_status": "suspended", "reason": reason}) + whatsapp_service.send_template_alert(seller.phone, "seller_suspended", {"name": seller.name, "reason": reason}) + + return Response({"message": f"Seller {seller.name} suspended successfully"}) + +@api_view(['POST']) +@permission_classes([IsCRMAdmin]) +def reinstate_seller(request, pk): + try: + seller = SupplierProfile.objects.get(pk=pk) + except SupplierProfile.DoesNotExist: + return Response({"detail": "Seller not found"}, status=404) + + old_status = seller.status + seller.status = 'approved' + seller.save() + + log_action(request.user, 'reinstate_seller', seller.id, {"old_status": old_status, "new_status": "approved"}) + whatsapp_service.send_template_alert(seller.phone, "seller_reinstated", {"name": seller.name}) + + return Response({"message": f"Seller {seller.name} reinstated successfully"}) + +@api_view(['POST']) +@permission_classes([IsCRMAdmin]) +def reset_seller_password(request, pk): + try: + seller = SupplierProfile.objects.get(pk=pk) + except SupplierProfile.DoesNotExist: + return Response({"detail": "Seller not found"}, status=404) + + # Trigger password reset flow + whatsapp_service.send_otp(seller.phone, otp_type="password_reset") + log_action(request.user, 'reset_password', seller.id, {"email": seller.email}) + + return Response({"message": "Password reset alert successfully dispatched"}) diff --git a/crm/views/wallet_views.py b/crm/views/wallet_views.py new file mode 100644 index 0000000..314b0a5 --- /dev/null +++ b/crm/views/wallet_views.py @@ -0,0 +1,83 @@ +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response +from crm.permissions import IsCRMAdmin +from crm.models import WithdrawalRequest, AuditLog +from seller_models.models import SupplierProfile +from crm.services.whatsapp_service import WhatsAppService +from crm.serializers import WithdrawalRequestSerializer +from django.utils import timezone + +whatsapp_service = WhatsAppService() + +@api_view(['GET']) +@permission_classes([IsCRMAdmin]) +def list_withdrawals(request): + reqs = WithdrawalRequest.objects.all() + serializer = WithdrawalRequestSerializer(reqs, many=True) + return Response(serializer.data) + +@api_view(['POST']) +@permission_classes([IsCRMAdmin]) +def approve_withdrawal(request, pk): + try: + w_req = WithdrawalRequest.objects.get(pk=pk) + except WithdrawalRequest.DoesNotExist: + return Response({"detail": "Withdrawal request not found"}, status=404) + + utr = request.data.get('utr_number') + if not utr: + return Response({"detail": "UTR number is required to confirm bank NEFT/IMPS transfer"}, status=400) + + w_req.status = 'approved' + w_req.utr_number = utr + w_req.resolved_at = timezone.now() + w_req.resolved_by = request.user + w_req.save() + + # Log to Audit log + AuditLog.objects.create( + admin=request.user, + action='approve_withdrawal', + target_type='withdrawal', + target_id=w_req.id, + details={"amount": str(w_req.amount), "utr": utr} + ) + + # WhatsApp Alert to Seller with UTR detail + try: + seller = SupplierProfile.objects.get(pk=w_req.seller_user_id) + whatsapp_service.send_template_alert(seller.phone, "withdrawal_success", { + "amount": str(w_req.amount), + "utr": utr + }) + except Exception: + pass + + return Response({"message": f"Withdrawal request #{w_req.id} approved with UTR {utr}"}) + +@api_view(['POST']) +@permission_classes([IsCRMAdmin]) +def reject_withdrawal(request, pk): + try: + w_req = WithdrawalRequest.objects.get(pk=pk) + except WithdrawalRequest.DoesNotExist: + return Response({"detail": "Withdrawal request not found"}, status=404) + + reason = request.data.get('reason', 'Verification failed') + w_req.status = 'rejected' + w_req.admin_note = reason + w_req.resolved_at = timezone.now() + w_req.resolved_by = request.user + w_req.save() + + # Log to Audit log + AuditLog.objects.create( + admin=request.user, + action='reject_withdrawal', + target_type='withdrawal', + target_id=w_req.id, + details={"reason": reason} + ) + + return Response({"message": f"Withdrawal request #{w_req.id} rejected"}) diff --git a/crmbackend.nginx.conf b/crmbackend.nginx.conf new file mode 100644 index 0000000..2f6f351 --- /dev/null +++ b/crmbackend.nginx.conf @@ -0,0 +1,12 @@ +server { + listen 80; + server_name crmbackend.local; + + location / { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/customer_models/__init__.py b/customer_models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/customer_models/__pycache__/__init__.cpython-312.pyc b/customer_models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..c99ac35 Binary files /dev/null and b/customer_models/__pycache__/__init__.cpython-312.pyc differ diff --git a/customer_models/__pycache__/admin.cpython-312.pyc b/customer_models/__pycache__/admin.cpython-312.pyc new file mode 100644 index 0000000..4efddc5 Binary files /dev/null and b/customer_models/__pycache__/admin.cpython-312.pyc differ diff --git a/customer_models/__pycache__/apps.cpython-312.pyc b/customer_models/__pycache__/apps.cpython-312.pyc new file mode 100644 index 0000000..9d807c9 Binary files /dev/null and b/customer_models/__pycache__/apps.cpython-312.pyc differ diff --git a/customer_models/__pycache__/models.cpython-312.pyc b/customer_models/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..b35072c Binary files /dev/null and b/customer_models/__pycache__/models.cpython-312.pyc differ diff --git a/customer_models/admin.py b/customer_models/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/customer_models/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/customer_models/apps.py b/customer_models/apps.py new file mode 100644 index 0000000..f4f79b8 --- /dev/null +++ b/customer_models/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CustomerModelsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'customer_models' diff --git a/customer_models/migrations/__init__.py b/customer_models/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/customer_models/migrations/__pycache__/__init__.cpython-312.pyc b/customer_models/migrations/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..8c3ba85 Binary files /dev/null and b/customer_models/migrations/__pycache__/__init__.cpython-312.pyc differ diff --git a/customer_models/models.py b/customer_models/models.py new file mode 100644 index 0000000..fa64f39 --- /dev/null +++ b/customer_models/models.py @@ -0,0 +1,25 @@ +from django.db import models + +class CustomerProfile(models.Model): + name = models.CharField(max_length=255) + email = models.EmailField(unique=True) + phone = models.CharField(max_length=20) + address = models.TextField() + status = models.CharField(max_length=50, default='active') # active, suspended, banned + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = 'customer_profile' + +class Order(models.Model): + customer = models.ForeignKey(CustomerProfile, on_delete=models.DO_NOTHING, related_name='orders') + merchant_transaction_id = models.CharField(max_length=100, unique=True) + amount = models.DecimalField(max_digits=10, decimal_places=2) + payment_status = models.CharField(max_length=50, default='pending') # pending, completed, failed, refunded + delivery_status = models.CharField(max_length=50, default='pending') # pending, shipped, delivered, returned + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = 'customer_order' diff --git a/customer_models/tests.py b/customer_models/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/customer_models/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/customer_models/views.py b/customer_models/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/customer_models/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/digihoxbackend/__init__.py b/digihoxbackend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/digihoxbackend/__pycache__/__init__.cpython-312.pyc b/digihoxbackend/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..20bf39e Binary files /dev/null and b/digihoxbackend/__pycache__/__init__.cpython-312.pyc differ diff --git a/digihoxbackend/__pycache__/db_router.cpython-312.pyc b/digihoxbackend/__pycache__/db_router.cpython-312.pyc new file mode 100644 index 0000000..8e15084 Binary files /dev/null and b/digihoxbackend/__pycache__/db_router.cpython-312.pyc differ diff --git a/digihoxbackend/__pycache__/settings.cpython-312.pyc b/digihoxbackend/__pycache__/settings.cpython-312.pyc new file mode 100644 index 0000000..f37d0b2 Binary files /dev/null and b/digihoxbackend/__pycache__/settings.cpython-312.pyc differ diff --git a/digihoxbackend/__pycache__/urls.cpython-312.pyc b/digihoxbackend/__pycache__/urls.cpython-312.pyc new file mode 100644 index 0000000..8c11adf Binary files /dev/null and b/digihoxbackend/__pycache__/urls.cpython-312.pyc differ diff --git a/digihoxbackend/__pycache__/wsgi.cpython-312.pyc b/digihoxbackend/__pycache__/wsgi.cpython-312.pyc new file mode 100644 index 0000000..24af9ee Binary files /dev/null and b/digihoxbackend/__pycache__/wsgi.cpython-312.pyc differ diff --git a/digihoxbackend/asgi.py b/digihoxbackend/asgi.py new file mode 100644 index 0000000..e5ff5a1 --- /dev/null +++ b/digihoxbackend/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for digihoxbackend 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/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'digihoxbackend.settings') + +application = get_asgi_application() diff --git a/digihoxbackend/db_router.py b/digihoxbackend/db_router.py new file mode 100644 index 0000000..7d01ad2 --- /dev/null +++ b/digihoxbackend/db_router.py @@ -0,0 +1,27 @@ +class CRMDatabaseRouter: + def db_for_read(self, model, **hints): + if model._meta.app_label == 'seller_models': + return 'seller_db' + elif model._meta.app_label == 'customer_models': + return 'customer_db' + return 'default' + + def db_for_write(self, model, **hints): + if model._meta.app_label == 'seller_models': + return 'seller_db' + elif model._meta.app_label == 'customer_models': + return 'customer_db' + return 'default' + + def allow_relation(self, obj1, obj2, **hints): + return True + + def allow_migrate(self, db, app_label, model_name=None, **hints): + if app_label == 'seller_models': + return db == 'seller_db' + elif app_label == 'customer_models': + return db == 'customer_db' + + if db in ['seller_db', 'customer_db']: + return False + return True diff --git a/digihoxbackend/settings.py b/digihoxbackend/settings.py new file mode 100644 index 0000000..3fc16e0 --- /dev/null +++ b/digihoxbackend/settings.py @@ -0,0 +1,141 @@ +import os +from pathlib import Path +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent.parent + +load_dotenv(BASE_DIR / '.env') + +SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-key') +DEBUG = os.getenv('DEBUG', 'True') == 'True' +ALLOWED_HOSTS = ['*'] + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'rest_framework', + 'corsheaders', + + 'crm', + 'seller_models', + 'customer_models', +] + +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 = 'digihoxbackend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'digihoxbackend.wsgi.application' + +# Fallback to SQLite if PG databases are not fully configured or raise errors during checks +USE_SQLITE = os.getenv('USE_SQLITE', 'True') == 'True' + +if USE_SQLITE: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + }, + 'seller_db': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'seller_db.sqlite3', + }, + 'customer_db': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'customer_db.sqlite3', + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.getenv('CRM_DB_NAME', 'digihox_crm'), + 'USER': os.getenv('CRM_DB_USER', 'postgres'), + 'PASSWORD': os.getenv('CRM_DB_PASSWORD', 'postgres'), + 'HOST': os.getenv('CRM_DB_HOST', '127.0.0.1'), + 'PORT': os.getenv('CRM_DB_PORT', '5432'), + }, + 'seller_db': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.getenv('SELLER_DB_NAME', 'seller_central'), + 'USER': os.getenv('SELLER_DB_USER', 'postgres'), + 'PASSWORD': os.getenv('SELLER_DB_PASSWORD', 'postgres'), + 'HOST': os.getenv('SELLER_DB_HOST', '127.0.0.1'), + 'PORT': os.getenv('SELLER_DB_PORT', '5432'), + }, + 'customer_db': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.getenv('CUSTOMER_DB_NAME', 'customer_website'), + 'USER': os.getenv('CUSTOMER_DB_USER', 'postgres'), + 'PASSWORD': os.getenv('CUSTOMER_DB_PASSWORD', 'postgres'), + 'HOST': os.getenv('CUSTOMER_DB_HOST', '127.0.0.1'), + 'PORT': os.getenv('CUSTOMER_DB_PORT', '5432'), + } + } + +DATABASE_ROUTERS = ['digihoxbackend.db_router.CRMDatabaseRouter'] + +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', + }, +] + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +STATIC_URL = 'static/' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), +} + +CORS_ALLOW_ALL_ORIGINS = True # Admin WireGuard VPN only; safe for development diff --git a/digihoxbackend/urls.py b/digihoxbackend/urls.py new file mode 100644 index 0000000..21bee3f --- /dev/null +++ b/digihoxbackend/urls.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from django.urls import path, include +from django.http import HttpResponse + +def home_view(request): + return HttpResponse("crm api running") + +urlpatterns = [ + path('', home_view, name='home'), + path('admin/', admin.site.urls), + path('api/crm/', include('crm.urls')), +] diff --git a/digihoxbackend/wsgi.py b/digihoxbackend/wsgi.py new file mode 100644 index 0000000..7ea6917 --- /dev/null +++ b/digihoxbackend/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for digihoxbackend 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/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'digihoxbackend.settings') + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..0f66d66 --- /dev/null +++ b/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', 'digihoxbackend.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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c45eeb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +Django>=4.2,<5.0 +djangorestframework>=3.14.0 +djangorestframework-simplejwt>=5.2.2 +django-cors-headers>=4.0.0 +python-dotenv>=1.0.0 +psycopg2-binary>=2.9.6 +requests>=2.31.0 +gunicorn==26.0.0 diff --git a/seller_models/__init__.py b/seller_models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/seller_models/__pycache__/__init__.cpython-312.pyc b/seller_models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..e4f7dfe Binary files /dev/null and b/seller_models/__pycache__/__init__.cpython-312.pyc differ diff --git a/seller_models/__pycache__/admin.cpython-312.pyc b/seller_models/__pycache__/admin.cpython-312.pyc new file mode 100644 index 0000000..adc3aba Binary files /dev/null and b/seller_models/__pycache__/admin.cpython-312.pyc differ diff --git a/seller_models/__pycache__/apps.cpython-312.pyc b/seller_models/__pycache__/apps.cpython-312.pyc new file mode 100644 index 0000000..da582fd Binary files /dev/null and b/seller_models/__pycache__/apps.cpython-312.pyc differ diff --git a/seller_models/__pycache__/models.cpython-312.pyc b/seller_models/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..16fd301 Binary files /dev/null and b/seller_models/__pycache__/models.cpython-312.pyc differ diff --git a/seller_models/admin.py b/seller_models/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/seller_models/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/seller_models/apps.py b/seller_models/apps.py new file mode 100644 index 0000000..ab3daef --- /dev/null +++ b/seller_models/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SellerModelsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'seller_models' diff --git a/seller_models/migrations/__init__.py b/seller_models/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/seller_models/migrations/__pycache__/__init__.cpython-312.pyc b/seller_models/migrations/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..9e82af6 Binary files /dev/null and b/seller_models/migrations/__pycache__/__init__.cpython-312.pyc differ diff --git a/seller_models/models.py b/seller_models/models.py new file mode 100644 index 0000000..0df3a32 --- /dev/null +++ b/seller_models/models.py @@ -0,0 +1,45 @@ +from django.db import models + +class SupplierProfile(models.Model): + name = models.CharField(max_length=255) + email = models.EmailField(unique=True) + phone = models.CharField(max_length=20) + address = models.TextField() + gstin = models.CharField(max_length=15) + pan = models.CharField(max_length=10) + status = models.CharField(max_length=50, default='pending_approval') + joined_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = 'supplier_profile' + +class Product(models.Model): + supplier = models.ForeignKey(SupplierProfile, on_delete=models.DO_NOTHING, related_name='products') + name = models.CharField(max_length=255) + price = models.DecimalField(max_digits=10, decimal_places=2) + stock = models.IntegerField(default=0) + status = models.CharField(max_length=50, default='pending_approval') + is_active = models.BooleanField(default=True) + + class Meta: + managed = False + db_table = 'product' + +class Wallet(models.Model): + supplier = models.ForeignKey(SupplierProfile, on_delete=models.DO_NOTHING) + balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00) + + class Meta: + managed = False + db_table = 'wallet' + +class WalletTransaction(models.Model): + wallet = models.ForeignKey(Wallet, on_delete=models.DO_NOTHING) + amount = models.DecimalField(max_digits=10, decimal_places=2) + tx_type = models.CharField(max_length=20) + status = models.CharField(max_length=50) + + class Meta: + managed = False + db_table = 'wallet_transaction' diff --git a/seller_models/tests.py b/seller_models/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/seller_models/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/seller_models/views.py b/seller_models/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/seller_models/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.