From 027afb7e2394a97b4ab1a6cc767dc51d01ece19e Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 7 Jun 2026 14:09:38 +0330 Subject: [PATCH] feat(contacts): store contact submissions --- apps/contacts/__init__.py | 1 + apps/contacts/admin.py | 56 ++++++++++++++++ apps/contacts/api/__init__.py | 1 + apps/contacts/api/serializers.py | 43 ++++++++++++ apps/contacts/api/throttles.py | 5 ++ apps/contacts/api/urls.py | 9 +++ apps/contacts/api/views.py | 40 +++++++++++ apps/contacts/apps.py | 6 ++ apps/contacts/migrations/0001_initial.py | 85 ++++++++++++++++++++++++ apps/contacts/migrations/__init__.py | 1 + apps/contacts/models.py | 36 ++++++++++ apps/contacts/tests/__init__.py | 1 + apps/contacts/tests/test_api_views.py | 44 ++++++++++++ config/settings/base.py | 2 + config/urls.py | 1 + 15 files changed, 331 insertions(+) create mode 100644 apps/contacts/__init__.py create mode 100644 apps/contacts/admin.py create mode 100644 apps/contacts/api/__init__.py create mode 100644 apps/contacts/api/serializers.py create mode 100644 apps/contacts/api/throttles.py create mode 100644 apps/contacts/api/urls.py create mode 100644 apps/contacts/api/views.py create mode 100644 apps/contacts/apps.py create mode 100644 apps/contacts/migrations/0001_initial.py create mode 100644 apps/contacts/migrations/__init__.py create mode 100644 apps/contacts/models.py create mode 100644 apps/contacts/tests/__init__.py create mode 100644 apps/contacts/tests/test_api_views.py diff --git a/apps/contacts/__init__.py b/apps/contacts/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/contacts/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/contacts/admin.py b/apps/contacts/admin.py new file mode 100644 index 0000000..d866d54 --- /dev/null +++ b/apps/contacts/admin.py @@ -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() diff --git a/apps/contacts/api/__init__.py b/apps/contacts/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/contacts/api/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/contacts/api/serializers.py b/apps/contacts/api/serializers.py new file mode 100644 index 0000000..cf7f4ab --- /dev/null +++ b/apps/contacts/api/serializers.py @@ -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 diff --git a/apps/contacts/api/throttles.py b/apps/contacts/api/throttles.py new file mode 100644 index 0000000..73775e0 --- /dev/null +++ b/apps/contacts/api/throttles.py @@ -0,0 +1,5 @@ +from rest_framework.throttling import AnonRateThrottle + + +class ContactSubmissionThrottle(AnonRateThrottle): + scope = "contact_submission" diff --git a/apps/contacts/api/urls.py b/apps/contacts/api/urls.py new file mode 100644 index 0000000..8be5184 --- /dev/null +++ b/apps/contacts/api/urls.py @@ -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"), +] diff --git a/apps/contacts/api/views.py b/apps/contacts/api/views.py new file mode 100644 index 0000000..4195a67 --- /dev/null +++ b/apps/contacts/api/views.py @@ -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, + ) diff --git a/apps/contacts/apps.py b/apps/contacts/apps.py new file mode 100644 index 0000000..a3472d3 --- /dev/null +++ b/apps/contacts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ContactsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.contacts" diff --git a/apps/contacts/migrations/0001_initial.py b/apps/contacts/migrations/0001_initial.py new file mode 100644 index 0000000..b17f57f --- /dev/null +++ b/apps/contacts/migrations/0001_initial.py @@ -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"), + ], + }, + ), + ] diff --git a/apps/contacts/migrations/__init__.py b/apps/contacts/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/contacts/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/contacts/models.py b/apps/contacts/models.py new file mode 100644 index 0000000..ab38a7a --- /dev/null +++ b/apps/contacts/models.py @@ -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}" diff --git a/apps/contacts/tests/__init__.py b/apps/contacts/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/contacts/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/contacts/tests/test_api_views.py b/apps/contacts/tests/test_api_views.py new file mode 100644 index 0000000..9f32618 --- /dev/null +++ b/apps/contacts/tests/test_api_views.py @@ -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()) diff --git a/config/settings/base.py b/config/settings/base.py index fb737e5..ad5450d 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -50,6 +50,7 @@ LOCAL_APPS = [ "apps.reports", "apps.logs", "apps.demos", + "apps.contacts", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS @@ -141,6 +142,7 @@ REST_FRAMEWORK = { "login_password": "5/10m", "login_otp": "5/10m", "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", } diff --git a/config/urls.py b/config/urls.py index c395570..8df7d73 100644 --- a/config/urls.py +++ b/config/urls.py @@ -25,6 +25,7 @@ urlpatterns = [ path("api/reports/", include("apps.reports.api.urls"), name="reports"), path("api/logs/", include("apps.logs.api.urls"), name="logs"), path("api/demo/", include("apps.demos.api.urls"), name="demos"), + path("api/contact/", include("apps.contacts.api.urls"), name="contacts"), ] if settings.DEBUG: