feat(contacts): store contact submissions
This commit is contained in:
1
apps/contacts/__init__.py
Normal file
1
apps/contacts/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
56
apps/contacts/admin.py
Normal file
56
apps/contacts/admin.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.contacts.models import ContactSubmission
|
||||||
|
from core.admins.base import BaseAdmin, SoftDeleteListFilter
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ContactSubmission)
|
||||||
|
class ContactSubmissionAdmin(BaseAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"full_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
"is_deleted",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
SoftDeleteListFilter,
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
)
|
||||||
|
readonly_fields = (
|
||||||
|
"id",
|
||||||
|
"ip_address",
|
||||||
|
"user_agent",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
)
|
||||||
|
fields = (
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
"status",
|
||||||
|
"ip_address",
|
||||||
|
"user_agent",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.display(description="Full name")
|
||||||
|
def full_name(self, obj):
|
||||||
|
return f"{obj.first_name} {obj.last_name}".strip()
|
||||||
1
apps/contacts/api/__init__.py
Normal file
1
apps/contacts/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
43
apps/contacts/api/serializers.py
Normal file
43
apps/contacts/api/serializers.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.contacts.models import ContactSubmission
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionCreateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ContactSubmission
|
||||||
|
fields = (
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_mobile(self, value):
|
||||||
|
clean_value = value.strip()
|
||||||
|
if len(clean_value) < 8:
|
||||||
|
raise serializers.ValidationError("Enter a valid mobile number.")
|
||||||
|
return clean_value
|
||||||
|
|
||||||
|
def validate_message(self, value):
|
||||||
|
clean_value = value.strip()
|
||||||
|
if len(clean_value) < 10:
|
||||||
|
raise serializers.ValidationError("Message must be at least 10 characters.")
|
||||||
|
return clean_value
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionResponseSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ContactSubmission
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
read_only_fields = fields
|
||||||
5
apps/contacts/api/throttles.py
Normal file
5
apps/contacts/api/throttles.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionThrottle(AnonRateThrottle):
|
||||||
|
scope = "contact_submission"
|
||||||
9
apps/contacts/api/urls.py
Normal file
9
apps/contacts/api/urls.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.contacts.api.views import ContactSubmissionView
|
||||||
|
|
||||||
|
app_name = "contacts"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", ContactSubmissionView.as_view(), name="contact-submit"),
|
||||||
|
]
|
||||||
40
apps/contacts/api/views.py
Normal file
40
apps/contacts/api/views.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.contacts.api.serializers import (
|
||||||
|
ContactSubmissionCreateSerializer,
|
||||||
|
ContactSubmissionResponseSerializer,
|
||||||
|
)
|
||||||
|
from apps.contacts.api.throttles import ContactSubmissionThrottle
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_ip(request):
|
||||||
|
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||||
|
if forwarded_for:
|
||||||
|
return forwarded_for.split(",")[0].strip()
|
||||||
|
return request.META.get("REMOTE_ADDR")
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionView(APIView):
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
throttle_classes = (ContactSubmissionThrottle,)
|
||||||
|
serializer_class = ContactSubmissionCreateSerializer
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=ContactSubmissionCreateSerializer,
|
||||||
|
responses={201: ContactSubmissionResponseSerializer},
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
serializer = self.serializer_class(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
submission = serializer.save(
|
||||||
|
ip_address=_get_client_ip(request),
|
||||||
|
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
ContactSubmissionResponseSerializer(submission).data,
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
6
apps/contacts/apps.py
Normal file
6
apps/contacts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ContactsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.contacts"
|
||||||
85
apps/contacts/migrations/0001_initial.py
Normal file
85
apps/contacts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Generated manually for contact submissions.
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ContactSubmission",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid7,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("is_deleted", models.BooleanField(default=False)),
|
||||||
|
("is_active", models.BooleanField(default=False)),
|
||||||
|
("first_name", models.CharField(max_length=120)),
|
||||||
|
("last_name", models.CharField(max_length=120)),
|
||||||
|
("email", models.EmailField(max_length=254)),
|
||||||
|
("mobile", models.CharField(max_length=32)),
|
||||||
|
("message", models.TextField()),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("new", "New"),
|
||||||
|
("contacted", "Contacted"),
|
||||||
|
("closed", "Closed"),
|
||||||
|
("spam", "Spam"),
|
||||||
|
],
|
||||||
|
default="new",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
("user_agent", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="created_%(app_label)s_%(class)s_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="updated_%(app_label)s_%(class)s_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "contact_submission",
|
||||||
|
"ordering": ("-created_at",),
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["id"], name="contactsubmission_id_idx"),
|
||||||
|
models.Index(fields=["created_at"], name="contact_created_at_idx"),
|
||||||
|
models.Index(fields=["status"], name="contact_status_idx"),
|
||||||
|
models.Index(fields=["email"], name="contact_email_idx"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
1
apps/contacts/migrations/__init__.py
Normal file
1
apps/contacts/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
36
apps/contacts/models.py
Normal file
36
apps/contacts/models.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from core.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmission(BaseModel):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
NEW = "new", "New"
|
||||||
|
CONTACTED = "contacted", "Contacted"
|
||||||
|
CLOSED = "closed", "Closed"
|
||||||
|
SPAM = "spam", "Spam"
|
||||||
|
|
||||||
|
first_name = models.CharField(max_length=120)
|
||||||
|
last_name = models.CharField(max_length=120)
|
||||||
|
email = models.EmailField()
|
||||||
|
mobile = models.CharField(max_length=32)
|
||||||
|
message = models.TextField()
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=Status.choices,
|
||||||
|
default=Status.NEW,
|
||||||
|
)
|
||||||
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
user_agent = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "contact_submission"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
indexes = (
|
||||||
|
models.Index(fields=("created_at",), name="contact_created_at_idx"),
|
||||||
|
models.Index(fields=("status",), name="contact_status_idx"),
|
||||||
|
models.Index(fields=("email",), name="contact_email_idx"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.first_name} {self.last_name} - {self.email}"
|
||||||
1
apps/contacts/tests/__init__.py
Normal file
1
apps/contacts/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
44
apps/contacts/tests/test_api_views.py
Normal file
44
apps/contacts/tests/test_api_views.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from apps.contacts.models import ContactSubmission
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionApiTests(APITestCase):
|
||||||
|
def test_public_user_can_submit_contact_form(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("contacts:contact-submit"),
|
||||||
|
{
|
||||||
|
"first_name": "Amin",
|
||||||
|
"last_name": "Test",
|
||||||
|
"email": "amin@example.com",
|
||||||
|
"mobile": "09938228438",
|
||||||
|
"message": "I need help with Qlockify reports.",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
HTTP_X_FORWARDED_FOR="203.0.113.10",
|
||||||
|
HTTP_USER_AGENT="test-agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
submission = ContactSubmission.objects.get()
|
||||||
|
self.assertEqual(submission.email, "amin@example.com")
|
||||||
|
self.assertEqual(submission.ip_address, "203.0.113.10")
|
||||||
|
self.assertEqual(submission.user_agent, "test-agent")
|
||||||
|
|
||||||
|
def test_rejects_short_message(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("contacts:contact-submit"),
|
||||||
|
{
|
||||||
|
"first_name": "Amin",
|
||||||
|
"last_name": "Test",
|
||||||
|
"email": "amin@example.com",
|
||||||
|
"mobile": "09938228438",
|
||||||
|
"message": "Hi",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertFalse(ContactSubmission.objects.exists())
|
||||||
@@ -50,6 +50,7 @@ LOCAL_APPS = [
|
|||||||
"apps.reports",
|
"apps.reports",
|
||||||
"apps.logs",
|
"apps.logs",
|
||||||
"apps.demos",
|
"apps.demos",
|
||||||
|
"apps.contacts",
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
@@ -141,6 +142,7 @@ REST_FRAMEWORK = {
|
|||||||
"login_password": "5/10m",
|
"login_password": "5/10m",
|
||||||
"login_otp": "5/10m",
|
"login_otp": "5/10m",
|
||||||
"demo_start": os.getenv("DEMO_START_RATE_LIMIT", "10/hour"),
|
"demo_start": os.getenv("DEMO_START_RATE_LIMIT", "10/hour"),
|
||||||
|
"contact_submission": os.getenv("CONTACT_SUBMISSION_RATE_LIMIT", "5/hour"),
|
||||||
},
|
},
|
||||||
"EXCEPTION_HANDLER": "core.exceptions.handlers.exception_handler",
|
"EXCEPTION_HANDLER": "core.exceptions.handlers.exception_handler",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ urlpatterns = [
|
|||||||
path("api/reports/", include("apps.reports.api.urls"), name="reports"),
|
path("api/reports/", include("apps.reports.api.urls"), name="reports"),
|
||||||
path("api/logs/", include("apps.logs.api.urls"), name="logs"),
|
path("api/logs/", include("apps.logs.api.urls"), name="logs"),
|
||||||
path("api/demo/", include("apps.demos.api.urls"), name="demos"),
|
path("api/demo/", include("apps.demos.api.urls"), name="demos"),
|
||||||
|
path("api/contact/", include("apps.contacts.api.urls"), name="contacts"),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
Reference in New Issue
Block a user