Compare commits

...

2 Commits

Author SHA1 Message Date
95f5e85e44 feat(workspaces): add bulk member import endpoints
Some checks are pending
Backend CI/CD / test (push) Waiting to run
Backend CI/CD / deploy (push) Blocked by required conditions
2026-06-18 22:53:34 +03:30
027afb7e23 feat(contacts): store contact submissions
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-06-07 14:09:38 +03:30
17 changed files with 776 additions and 3 deletions

View File

@@ -0,0 +1 @@

56
apps/contacts/admin.py Normal file
View 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()

View File

@@ -0,0 +1 @@

View 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

View File

@@ -0,0 +1,5 @@
from rest_framework.throttling import AnonRateThrottle
class ContactSubmissionThrottle(AnonRateThrottle):
scope = "contact_submission"

View 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"),
]

View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ContactsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.contacts"

View 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"),
],
},
),
]

View File

@@ -0,0 +1 @@

36
apps/contacts/models.py Normal file
View 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}"

View File

@@ -0,0 +1 @@

View 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())

View File

@@ -1,3 +1,8 @@
from decimal import Decimal, InvalidOperation
from django.core import signing
from django.core.cache import cache
from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404
from rest_framework import status
@@ -18,6 +23,7 @@ from apps.notifications.services import (
)
from apps.projects.models import ProjectUserRate
from apps.projects.services.access import filter_projects_for_user
from apps.users.models import User
from apps.workspaces.api.permissions import (
CanWorkspaceManageMembers,
IsWorkspaceAdmin,
@@ -56,6 +62,20 @@ from core.services.cache import (
REFERENCE_CACHE_TTL_SECONDS = 60 * 5
PRICE_UNITS_CACHE_TTL_SECONDS = 60 * 60
MEMBER_IMPORT_CACHE_PREFIX = "workspace-member-import"
MEMBER_IMPORT_TTL_SECONDS = 60 * 15
MEMBER_IMPORT_MAX_ROWS = 500
def _normalize_digits(value):
if value is None:
return ""
translation = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
return str(value).translate(translation).strip()
def _import_cache_key(token):
return f"{MEMBER_IMPORT_CACHE_PREFIX}:{token}"
class WorkspaceViewSet(ModelViewSet):
@@ -216,6 +236,288 @@ class WorkspaceMembershipViewSet(ModelViewSet):
return [IsAuthenticated()]
def _ensure_import_permission(self, request, workspace):
if getattr(request.user, "is_demo", False):
return Response(
{"detail": "Demo accounts cannot import workspace members."},
status=status.HTTP_403_FORBIDDEN,
)
permission = IsWorkspaceAdmin()
if not permission.has_object_permission(request, self, workspace):
return Response(
{"detail": "You must be a Workspace Admin or Owner to import members."},
status=status.HTTP_403_FORBIDDEN,
)
return None
def _validate_import_rows(self, request, workspace, rows):
if not isinstance(rows, list):
rows = []
result_rows = []
seen_mobiles = set()
valid_count = 0
invalid_count = 0
allowed_roles = {
WorkspaceMembership.Role.ADMIN,
WorkspaceMembership.Role.MEMBER,
WorkspaceMembership.Role.GUEST,
}
existing_memberships = {
str(membership.user_id): membership
for membership in WorkspaceMembership.all_objects.filter(
workspace=workspace,
is_deleted=False,
)
}
if len(rows) > MEMBER_IMPORT_MAX_ROWS:
return {
"can_commit": False,
"summary": {
"total": len(rows),
"valid": 0,
"invalid": len(rows),
},
"rows": [
{
"line": None,
"mobile": "",
"role": WorkspaceMembership.Role.MEMBER,
"hourly_rate": "",
"currency": "",
"status": "invalid",
"action": "none",
"user": None,
"messages": [f"Import is limited to {MEMBER_IMPORT_MAX_ROWS} rows."],
}
],
}
for index, raw_row in enumerate(rows, start=1):
raw_row = raw_row if isinstance(raw_row, dict) else {}
line = raw_row.get("line") or index + 1
mobile = _normalize_digits(raw_row.get("mobile"))
role = (str(raw_row.get("role") or WorkspaceMembership.Role.MEMBER).strip().lower())
hourly_rate_raw = _normalize_digits(raw_row.get("hourly_rate"))
currency = str(raw_row.get("currency") or "").strip().upper()
messages = []
user = None
normalized_rate = ""
if not mobile:
messages.append("Mobile is required.")
elif mobile in seen_mobiles:
messages.append("This mobile appears more than once in the import file.")
else:
seen_mobiles.add(mobile)
user = User.objects.filter(mobile=mobile).first()
if not user:
messages.append("No user exists with this mobile number.")
elif str(user.id) in existing_memberships:
messages.append("This user is already a member of the workspace.")
if role == WorkspaceMembership.Role.OWNER:
messages.append("Owner role cannot be imported.")
elif role not in allowed_roles:
messages.append("Role must be admin, member, or guest.")
elif not can_assign_workspace_role(request.user, workspace, role):
messages.append("You do not have permission to assign this role.")
has_rate = bool(hourly_rate_raw)
has_currency = bool(currency)
if has_rate != has_currency:
messages.append("Hourly rate and currency must be provided together.")
elif has_rate and has_currency:
try:
parsed_rate = Decimal(hourly_rate_raw.replace(",", ""))
if parsed_rate <= Decimal("0"):
messages.append("Hourly rate must be greater than zero.")
else:
normalized_rate = f"{parsed_rate:.2f}"
except (InvalidOperation, ValueError):
messages.append("Hourly rate must be a valid number.")
if not PriceUnit.objects.filter(code=currency, is_deleted=False).exists():
messages.append("Currency is invalid.")
row_status = "invalid" if messages else "valid"
if messages:
invalid_count += 1
else:
valid_count += 1
result_rows.append(
{
"line": line,
"mobile": mobile,
"role": role,
"hourly_rate": normalized_rate,
"currency": currency,
"status": row_status,
"action": "add_member" if row_status == "valid" else "none",
"user": (
{
"id": str(user.id),
"full_name": user.full_name or user.mobile,
"mobile": user.mobile,
}
if user
else None
),
"messages": messages,
}
)
return {
"can_commit": bool(rows) and invalid_count == 0,
"summary": {
"total": len(rows),
"valid": valid_count,
"invalid": invalid_count,
},
"rows": result_rows,
}
def _build_import_response(self, request, workspace, rows, *, include_token):
payload = self._validate_import_rows(request, workspace, rows)
if include_token and payload["can_commit"]:
token = signing.dumps(
{
"workspace": str(workspace.id),
"user": str(request.user.id),
},
salt=MEMBER_IMPORT_CACHE_PREFIX,
)
cache.set(
_import_cache_key(token),
{
"workspace": str(workspace.id),
"user": str(request.user.id),
"rows": rows,
},
timeout=MEMBER_IMPORT_TTL_SECONDS,
)
payload["import_token"] = token
else:
payload["import_token"] = None
return payload
@action(detail=False, methods=["post"], url_path="import/validate")
def import_validate(self, request):
workspace_id = request.data.get("workspace")
if not workspace_id:
return Response(
{"workspace": ["This field is required."]},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
permission_response = self._ensure_import_permission(request, workspace)
if permission_response is not None:
return permission_response
payload = self._build_import_response(
request,
workspace,
request.data.get("rows") or [],
include_token=True,
)
return Response(payload, status=status.HTTP_200_OK)
@action(detail=False, methods=["post"], url_path="import/commit")
def import_commit(self, request):
workspace_id = request.data.get("workspace")
import_token = request.data.get("import_token")
if not workspace_id:
return Response(
{"workspace": ["This field is required."]},
status=status.HTTP_400_BAD_REQUEST,
)
if not import_token:
return Response(
{"import_token": ["This field is required."]},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
permission_response = self._ensure_import_permission(request, workspace)
if permission_response is not None:
return permission_response
try:
signed_payload = signing.loads(
import_token,
salt=MEMBER_IMPORT_CACHE_PREFIX,
max_age=MEMBER_IMPORT_TTL_SECONDS,
)
except signing.BadSignature:
return Response(
{"detail": "Import validation has expired. Please validate the file again."},
status=status.HTTP_400_BAD_REQUEST,
)
cached_payload = cache.get(_import_cache_key(import_token))
if (
not cached_payload
or signed_payload.get("workspace") != str(workspace.id)
or signed_payload.get("user") != str(request.user.id)
or cached_payload.get("workspace") != str(workspace.id)
or cached_payload.get("user") != str(request.user.id)
):
return Response(
{"detail": "Import validation has expired. Please validate the file again."},
status=status.HTTP_400_BAD_REQUEST,
)
rows = cached_payload.get("rows") or []
validation_payload = self._validate_import_rows(request, workspace, rows)
if not validation_payload["can_commit"]:
validation_payload["import_token"] = None
return Response(validation_payload, status=status.HTTP_400_BAD_REQUEST)
memberships = []
rate_count = 0
with transaction.atomic():
for row in validation_payload["rows"]:
user_id = row["user"]["id"]
membership = WorkspaceMembership.objects.create(
workspace=workspace,
user_id=user_id,
role=row["role"],
is_active=True,
)
memberships.append(membership)
notify_workspace_membership_added(
actor=request.user,
recipient=membership.user,
workspace=workspace,
role=membership.role,
)
if row["hourly_rate"] and row["currency"]:
upsert_workspace_user_rate(
workspace=workspace,
user_id=user_id,
hourly_rate=Decimal(row["hourly_rate"]),
currency=row["currency"],
)
rate_count += 1
cache.delete(_import_cache_key(import_token))
return Response(
{
"created_memberships": len(memberships),
"created_or_updated_rates": rate_count,
"memberships": WorkspaceMembershipSerializer(
memberships,
many=True,
context=self.get_serializer_context(),
).data,
},
status=status.HTTP_201_CREATED,
)
def list(self, request, *args, **kwargs):
workspace_id = request.query_params.get("workspace")
if not workspace_id:

View File

@@ -1,4 +1,5 @@
from datetime import timedelta
from decimal import Decimal
from django.utils import timezone
from rest_framework.test import APITestCase
@@ -7,7 +8,13 @@ from apps.clients.models import Client
from apps.projects.models import Project
from apps.tags.models import Tag
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
from apps.workspaces.models import (
HourlyRateHistory,
PriceUnit,
Workspace,
WorkspaceMembership,
WorkspaceUserRate,
)
class WorkspaceCapabilityTests(APITestCase):
@@ -298,6 +305,139 @@ class WorkspaceCapabilityTests(APITestCase):
self.assertEqual(update_response.status_code, 403)
self.assertEqual(delete_response.status_code, 403)
def test_owner_can_validate_and_commit_member_import_with_rate(self):
PriceUnit.objects.create(code="IRT", name="Toman", local_name="Toman", symbol="Toman")
target = self._user(21)
self.client.force_authenticate(user=self.owner)
validate_response = self.client.post(
"/api/workspace-memberships/import/validate/",
{
"workspace": str(self.workspace.id),
"rows": [
{
"line": 2,
"mobile": target.mobile,
"role": "member",
"hourly_rate": "150000",
"currency": "IRT",
}
],
},
format="json",
)
self.assertEqual(validate_response.status_code, 200)
self.assertTrue(validate_response.data["can_commit"])
self.assertEqual(validate_response.data["summary"]["valid"], 1)
self.assertTrue(validate_response.data["import_token"])
commit_response = self.client.post(
"/api/workspace-memberships/import/commit/",
{
"workspace": str(self.workspace.id),
"import_token": validate_response.data["import_token"],
},
format="json",
)
self.assertEqual(commit_response.status_code, 201)
self.assertEqual(commit_response.data["created_memberships"], 1)
self.assertEqual(commit_response.data["created_or_updated_rates"], 1)
self.assertTrue(
WorkspaceMembership.objects.filter(
workspace=self.workspace,
user=target,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
).exists()
)
rate = WorkspaceUserRate.objects.get(workspace=self.workspace, user=target)
self.assertEqual(rate.hourly_rate, Decimal("150000.00"))
self.assertEqual(rate.currency, "IRT")
self.assertTrue(
HourlyRateHistory.objects.filter(
workspace=self.workspace,
user=target,
hourly_rate=Decimal("150000.00"),
currency="IRT",
).exists()
)
def test_member_import_rejects_invalid_rows(self):
PriceUnit.objects.create(code="USD", name="Dollar", local_name="Dollar", symbol="$")
target = self._user(22)
self.client.force_authenticate(user=self.owner)
response = self.client.post(
"/api/workspace-memberships/import/validate/",
{
"workspace": str(self.workspace.id),
"rows": [
{"line": 2, "mobile": "", "role": "member"},
{"line": 3, "mobile": "09120000000", "role": "member"},
{"line": 4, "mobile": target.mobile, "role": "owner"},
{"line": 5, "mobile": target.mobile, "role": "member"},
{"line": 6, "mobile": self.member.mobile, "role": "member"},
{"line": 7, "mobile": self.guest.mobile, "role": "guest", "hourly_rate": "10"},
{"line": 8, "mobile": self.admin.mobile, "role": "admin", "hourly_rate": "0", "currency": "USD"},
{"line": 9, "mobile": self.extra_owner.mobile, "role": "guest", "hourly_rate": "10", "currency": "XXX"},
],
},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertFalse(response.data["can_commit"])
self.assertIsNone(response.data["import_token"])
self.assertEqual(response.data["summary"]["invalid"], 8)
def test_admin_import_follows_role_assignment_rules(self):
target = self._user(23)
self.client.force_authenticate(user=self.admin)
response = self.client.post(
"/api/workspace-memberships/import/validate/",
{
"workspace": str(self.workspace.id),
"rows": [{"line": 2, "mobile": target.mobile, "role": "admin"}],
},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertFalse(response.data["can_commit"])
self.assertIn("permission", response.data["rows"][0]["messages"][0].lower())
def test_member_cannot_import_workspace_members(self):
target = self._user(24)
self.client.force_authenticate(user=self.member)
response = self.client.post(
"/api/workspace-memberships/import/validate/",
{
"workspace": str(self.workspace.id),
"rows": [{"line": 2, "mobile": target.mobile, "role": "member"}],
},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_import_commit_rejects_expired_token(self):
self.client.force_authenticate(user=self.owner)
response = self.client.post(
"/api/workspace-memberships/import/commit/",
{
"workspace": str(self.workspace.id),
"import_token": "invalid-token",
},
format="json",
)
self.assertEqual(response.status_code, 400)
def test_admin_can_delete_only_owned_clients_tags_and_projects(self):
self.client.force_authenticate(user=self.owner)
owner_client_response = self.client.post(

View File

@@ -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",
}

View File

@@ -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: