Compare commits

...

10 Commits

62 changed files with 5021 additions and 1847 deletions

11
.coveragerc Normal file
View File

@@ -0,0 +1,11 @@
[run]
branch = True
source =
apps
omit =
*/migrations/*
*/tests/*
[report]
show_missing = True
skip_covered = False

View File

@@ -42,3 +42,7 @@ TIME_ZONE=Asia/Tehran
SMS_APIKEY=
BASE_URL=
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_REDIRECT_URI=http://localhost:8000/api/users/oauth/google/callback/
GOOGLE_OAUTH_FRONTEND_CALLBACK_URL=http://localhost:5173/auth/google/callback

193
README.md Normal file
View File

@@ -0,0 +1,193 @@
# Qlockify Backend
Backend API and background job layer for Qlockify.
## Repository
- Main deployment entrypoint: `https://git.amiirkhl.ir/Qlockify/qlockify-core-deployment.git`
- Frontend repository declared by `origin`: `https://git.amiirkhl.ir/Qlockify/qlockify-frontend-deployment.git`
- Backend repository declared by `origin`: `https://git.amiirkhl.ir/Qlockify/qlockify-backend-deployment.git`
## What This Repo Contains
- Django 5 API
- DRF-based authentication and business APIs
- JWT auth
- Redis-backed caching
- Celery tasks
- audit/logging infrastructure
- report generation and exports
- Google sign-in backend flow
## Stack
- Python `3.14`
- Django `5.2`
- Django REST Framework
- PostgreSQL
- Redis
- Celery
- Simple JWT
- drf-spectacular
- django-auditlog
## Project Layout
```text
qlockify-backend/
apps/
users/
workspaces/
clients/
projects/
tags/
time_entries/
reports/
notifications/
logs/
config/
settings/
services/
core/
requirements/
manage.py
```
## Main Domains
- `users`: auth, OTP, profile, Google OAuth link flow
- `workspaces`: workspace membership, permissions, rates
- `clients`: client CRUD
- `projects`: project CRUD
- `tags`: tag CRUD
- `time_entries`: timer and timesheet data
- `reports`: chart/table/day-details/export
- `notifications`: in-app notifications and SSE stream
- `logs`: workspace activity logs
## Local Development
### 1. Create environment
```powershell
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install -r requirements\base.txt
pip install -r requirements\dev.txt
```
### 2. Configure environment variables
Copy and fill:
```text
.env.sample -> .env
```
At minimum configure:
- `DJANGO_SECRET_KEY`
- `POSTGRES_DB`
- `POSTGRES_USER`
- `POSTGRES_PASSWORD`
- `POSTGRES_HOST`
- `POSTGRES_PORT`
- `REDIS_HOST`
- `REDIS_PORT`
### 3. Run migrations
```powershell
.venv\Scripts\python.exe manage.py migrate
```
### 4. Start the API
```powershell
.venv\Scripts\python.exe manage.py runserver
```
Default local URL:
- `http://localhost:8000`
## Useful Commands
Run all tests:
```powershell
.venv\Scripts\python.exe manage.py test --settings=config.settings.test
```
Run coverage:
```powershell
.venv\Scripts\python.exe -m coverage run manage.py test --settings=config.settings.test
.venv\Scripts\python.exe -m coverage report
```
Run Ruff:
```powershell
.venv\Scripts\python.exe -m ruff check .
```
Run Black:
```powershell
.venv\Scripts\python.exe -m black .
```
Start Celery worker:
```powershell
celery -A config worker -l INFO
```
Start Celery beat:
```powershell
celery -A config beat -l INFO
```
## Authentication
Supported auth flows:
- password login
- OTP send + OTP login
- password reset via OTP
- Google sign-in with backend callback
Google sign-in is mobile-first:
- Google proves email ownership
- first-time Google users must enter a mobile number
- if that mobile already belongs to an existing account, OTP verification is required before linking Google
- email matches alone do not auto-link accounts
Required Google env vars:
- `GOOGLE_OAUTH_CLIENT_ID`
- `GOOGLE_OAUTH_CLIENT_SECRET`
- `GOOGLE_OAUTH_REDIRECT_URI`
- `GOOGLE_OAUTH_FRONTEND_CALLBACK_URL`
## Caching and Async Work
- Redis is used for cache, OTP state, and Celery broker/result backend
- query-heavy report endpoints use targeted server-side caching
- workspace-scoped reference data uses targeted caching with namespace invalidation
- Celery handles async jobs such as SMS and report export generation
## API Documentation
The project exposes OpenAPI/Swagger via DRF Spectacular in deployment.
Production docs access is handled by Nginx in the deployment repo.
## Notes
- `.env` is intentionally not committed
- deployment-specific setup belongs in the deployment repository, not here
- domain, SSL, Nginx, Docker Compose, and host-level operations are documented in the deployment repo

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,67 @@
from django.test import TestCase
from rest_framework.exceptions import ValidationError
from apps.clients.models import Client
from apps.clients.services.clients import create_client, update_client
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
class ClientServiceTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(mobile="09120000001", password="secret123")
cls.member = User.objects.create_user(mobile="09120000002", password="secret123")
cls.outsider = User.objects.create_user(mobile="09120000003", password="secret123")
cls.workspace = Workspace.objects.create(name="Clients", owner=cls.owner)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
def test_create_client_creates_record_for_workspace_member(self):
client = create_client(
self.member,
self.workspace.id,
"Acme",
notes="Priority account",
)
self.assertEqual(client.name, "Acme")
self.assertEqual(client.notes, "Priority account")
self.assertEqual(client.workspace, self.workspace)
self.assertEqual(client.created_by, self.member)
def test_create_client_rejects_non_member(self):
with self.assertRaises(ValidationError) as exc:
create_client(self.outsider, self.workspace.id, "Acme")
self.assertIn("workspace", exc.exception.detail)
def test_create_client_rejects_duplicate_name_in_workspace(self):
Client.objects.create(workspace=self.workspace, name="Acme")
with self.assertRaises(ValidationError) as exc:
create_client(self.owner, self.workspace.id, "Acme")
self.assertIn("name", exc.exception.detail)
def test_update_client_updates_name_and_notes(self):
client = Client.objects.create(workspace=self.workspace, name="Acme", notes="Old")
updated = update_client(client, name="Globex", notes="New")
self.assertEqual(updated.name, "Globex")
self.assertEqual(updated.notes, "New")
def test_update_client_rejects_duplicate_new_name(self):
Client.objects.create(workspace=self.workspace, name="Globex")
client = Client.objects.create(workspace=self.workspace, name="Acme")
with self.assertRaises(ValidationError) as exc:
update_client(client, name="Globex")
self.assertIn("name", exc.exception.detail)

View File

@@ -0,0 +1,125 @@
from rest_framework.test import APITestCase
from apps.clients.models import Client
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
class ClientViewTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(mobile="09120000011", password="secret123")
cls.admin = User.objects.create_user(mobile="09120000012", password="secret123")
cls.second_admin = User.objects.create_user(mobile="09120000013", password="secret123")
cls.member = User.objects.create_user(mobile="09120000014", password="secret123")
cls.guest = User.objects.create_user(mobile="09120000015", password="secret123")
cls.outsider = User.objects.create_user(mobile="09120000016", password="secret123")
cls.workspace = Workspace.objects.create(name="Clients API", owner=cls.owner)
for user, role in (
(cls.admin, WorkspaceMembership.Role.ADMIN),
(cls.second_admin, WorkspaceMembership.Role.ADMIN),
(cls.member, WorkspaceMembership.Role.MEMBER),
(cls.guest, WorkspaceMembership.Role.GUEST),
):
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=user,
role=role,
is_active=True,
)
cls.other_workspace = Workspace.objects.create(name="Other", owner=cls.outsider)
cls.visible_client = Client.objects.create(workspace=cls.workspace, name="Visible")
cls.hidden_client = Client.objects.create(workspace=cls.other_workspace, name="Hidden")
cls.admin_owned_client = Client.objects.create(
workspace=cls.workspace,
name="Admin Owned",
created_by=cls.admin,
updated_by=cls.admin,
)
def test_list_only_returns_clients_for_member_workspaces(self):
self.client.force_authenticate(user=self.member)
response = self.client.get("/api/clients/")
self.assertEqual(response.status_code, 200)
results = (
response.data
if isinstance(response.data, list)
else response.data.get("results")
or response.data.get("items")
or response.data.get("notifications")
or []
)
names = {item["name"] for item in results}
self.assertIn("Visible", names)
self.assertNotIn("Hidden", names)
def test_owner_can_create_client(self):
self.client.force_authenticate(user=self.owner)
response = self.client.post(
"/api/clients/",
{
"workspace_id": str(self.workspace.id),
"name": "Created",
"notes": "Important",
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["name"], "Created")
def test_member_cannot_create_client(self):
self.client.force_authenticate(user=self.member)
response = self.client.post(
"/api/clients/",
{
"workspace_id": str(self.workspace.id),
"name": "Created",
},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_admin_can_update_client(self):
self.client.force_authenticate(user=self.admin)
response = self.client.patch(
f"/api/clients/{self.visible_client.id}/",
{"name": "Renamed"},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["name"], "Renamed")
def test_admin_can_delete_only_client_they_created(self):
self.client.force_authenticate(user=self.second_admin)
forbidden = self.client.delete(f"/api/clients/{self.admin_owned_client.id}/")
self.assertEqual(forbidden.status_code, 403)
self.client.force_authenticate(user=self.admin)
allowed = self.client.delete(f"/api/clients/{self.admin_owned_client.id}/")
self.assertEqual(allowed.status_code, 204)
self.assertTrue(Client.all_objects.get(id=self.admin_owned_client.id).is_deleted)
def test_owner_can_delete_any_client(self):
client = Client.objects.create(
workspace=self.workspace,
name="Owner Delete",
created_by=self.admin,
updated_by=self.admin,
)
self.client.force_authenticate(user=self.owner)
response = self.client.delete(f"/api/clients/{client.id}/")
self.assertEqual(response.status_code, 204)
self.assertTrue(Client.all_objects.get(id=client.id).is_deleted)

View File

@@ -1,23 +1,40 @@
from __future__ import annotations
from datetime import timedelta
import pytest
from auditlog.models import LogEntry
from rest_framework_simplejwt.tokens import AccessToken
from rest_framework.test import APIClient
from rest_framework.test import APITestCase
from apps.reports.models import ReportExportJob
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
@pytest.fixture()
def api_client():
return APIClient()
class WorkspaceLogViewTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.owner = cls._user(1)
cls.admin = cls._user(2)
cls.member = cls._user(3)
cls.outsider = cls._user(4)
cls.workspace = Workspace.objects.create(
name="Logs WS",
description="",
owner=cls.owner,
)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.admin,
role=WorkspaceMembership.Role.ADMIN,
is_active=True,
)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
def _user(index: int) -> User:
@staticmethod
def _user(index):
return User.objects.create_user(
mobile=f"093355500{index:02d}",
password="secret123",
@@ -25,157 +42,120 @@ def _user(index: int) -> User:
last_name="User",
)
@pytest.fixture()
def owner(db):
return _user(1)
@pytest.fixture()
def admin(db):
return _user(2)
@pytest.fixture()
def member(db):
return _user(3)
@pytest.fixture()
def outsider(db):
return _user(4)
@pytest.fixture()
def workspace(owner, admin, member):
workspace = Workspace.objects.create(name="Logs WS", description="", owner=owner)
WorkspaceMembership.objects.create(
workspace=workspace,
user=admin,
role=WorkspaceMembership.Role.ADMIN,
is_active=True,
)
WorkspaceMembership.objects.create(
workspace=workspace,
user=member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
return workspace
def _auth_headers(user: User) -> dict:
@staticmethod
def _auth_headers(user):
token = str(AccessToken.for_user(user))
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
def _create_tag(client: APIClient, user: User, workspace: Workspace, *, name="Audit Tag"):
return client.post(
def _create_tag(self, user, *, name="Audit Tag"):
return self.client.post(
"/api/tags/",
{"workspace_id": str(workspace.id), "name": name, "color": "#123456"},
{
"workspace_id": str(self.workspace.id),
"name": name,
"color": "#123456",
},
format="json",
**_auth_headers(user),
**self._auth_headers(user),
)
def test_owner_and_admin_can_list_workspace_logs(self):
create_response = self._create_tag(self.owner)
self.assertEqual(create_response.status_code, 201)
@pytest.mark.django_db
def test_owner_and_admin_can_list_workspace_logs(api_client, owner, admin, workspace):
create_response = _create_tag(api_client, owner, workspace)
assert create_response.status_code == 201
owner_response = api_client.get(
f"/api/logs/?workspace={workspace.id}",
**_auth_headers(owner),
owner_response = self.client.get(
f"/api/logs/?workspace={self.workspace.id}",
**self._auth_headers(self.owner),
)
admin_response = api_client.get(
f"/api/logs/?workspace={workspace.id}",
**_auth_headers(admin),
admin_response = self.client.get(
f"/api/logs/?workspace={self.workspace.id}",
**self._auth_headers(self.admin),
)
assert owner_response.status_code == 200
assert admin_response.status_code == 200
assert owner_response.data["items"][0]["section"] == "tags"
self.assertEqual(owner_response.status_code, 200)
self.assertEqual(admin_response.status_code, 200)
self.assertEqual(owner_response.data["items"][0]["section"], "tags")
def test_member_and_non_member_cannot_list_workspace_logs(self):
self._create_tag(self.owner)
@pytest.mark.django_db
def test_member_and_non_member_cannot_list_workspace_logs(api_client, owner, member, outsider, workspace):
_create_tag(api_client, owner, workspace)
member_response = api_client.get(
f"/api/logs/?workspace={workspace.id}",
**_auth_headers(member),
member_response = self.client.get(
f"/api/logs/?workspace={self.workspace.id}",
**self._auth_headers(self.member),
)
outsider_response = api_client.get(
f"/api/logs/?workspace={workspace.id}",
**_auth_headers(outsider),
outsider_response = self.client.get(
f"/api/logs/?workspace={self.workspace.id}",
**self._auth_headers(self.outsider),
)
assert member_response.status_code == 403
assert outsider_response.status_code == 403
self.assertEqual(member_response.status_code, 403)
self.assertEqual(outsider_response.status_code, 403)
def test_jwt_authenticated_writes_capture_actor_and_workspace_metadata(self):
response = self._create_tag(self.owner, name="JWT Tag")
self.assertEqual(response.status_code, 201)
@pytest.mark.django_db
def test_jwt_authenticated_writes_capture_actor_and_workspace_metadata(api_client, owner, workspace):
response = _create_tag(api_client, owner, workspace, name="JWT Tag")
assert response.status_code == 201
log_entry = LogEntry.objects.filter(content_type__app_label="tags").latest("timestamp")
assert log_entry.actor_id == owner.id
assert log_entry.additional_data["workspace_id"] == str(workspace.id)
assert log_entry.additional_data["section"] == "tags"
@pytest.mark.django_db
def test_logs_support_section_filter_and_detail(api_client, owner, workspace):
tag_response = _create_tag(api_client, owner, workspace, name="Filtered Tag")
assert tag_response.status_code == 201
list_response = api_client.get(
f"/api/logs/?workspace={workspace.id}&section=tags",
**_auth_headers(owner),
log_entry = LogEntry.objects.filter(content_type__app_label="tags").latest(
"timestamp"
)
assert list_response.status_code == 200
assert list_response.data["items"]
self.assertEqual(log_entry.actor_id, self.owner.id)
self.assertEqual(log_entry.additional_data["workspace_id"], str(self.workspace.id))
self.assertEqual(log_entry.additional_data["section"], "tags")
def test_logs_support_section_filter_and_detail(self):
tag_response = self._create_tag(self.owner, name="Filtered Tag")
self.assertEqual(tag_response.status_code, 201)
list_response = self.client.get(
f"/api/logs/?workspace={self.workspace.id}&section=tags",
**self._auth_headers(self.owner),
)
self.assertEqual(list_response.status_code, 200)
self.assertTrue(list_response.data["items"])
log_id = list_response.data["items"][0]["id"]
detail_response = api_client.get(
detail_response = self.client.get(
f"/api/logs/{log_id}/",
**_auth_headers(owner),
**self._auth_headers(self.owner),
)
assert detail_response.status_code == 200
assert detail_response.data["target"]["name"] == "Filtered Tag"
assert detail_response.data["changes"]
self.assertEqual(detail_response.status_code, 200)
self.assertEqual(detail_response.data["target"]["name"], "Filtered Tag")
self.assertTrue(detail_response.data["changes"])
@pytest.mark.django_db
def test_soft_delete_and_actorless_background_logs_are_filtered(api_client, owner, workspace):
create_response = _create_tag(api_client, owner, workspace, name="Delete Me")
assert create_response.status_code == 201
def test_soft_delete_and_actorless_background_logs_are_filtered(self):
create_response = self._create_tag(self.owner, name="Delete Me")
self.assertEqual(create_response.status_code, 201)
tag_id = create_response.data["id"]
delete_response = api_client.delete(
delete_response = self.client.delete(
f"/api/tags/{tag_id}/",
**_auth_headers(owner),
**self._auth_headers(self.owner),
)
assert delete_response.status_code == 204
self.assertEqual(delete_response.status_code, 204)
ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.PDF,
filters={"workspace": str(workspace.id)},
filters={"workspace": str(self.workspace.id)},
status=ReportExportJob.Status.PENDING,
)
response = api_client.get(
f"/api/logs/?workspace={workspace.id}&event=delete",
**_auth_headers(owner),
response = self.client.get(
f"/api/logs/?workspace={self.workspace.id}&event=delete",
**self._auth_headers(self.owner),
)
assert response.status_code == 200
assert any(item["event"] == "delete" and item["section"] == "tags" for item in response.data["items"])
assert all(item["section"] != "report_exports" for item in response.data["items"])
self.assertEqual(response.status_code, 200)
self.assertTrue(
any(
item["event"] == "delete" and item["section"] == "tags"
for item in response.data["items"]
)
)
self.assertTrue(
all(item["section"] != "report_exports" for item in response.data["items"])
)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,126 @@
import json
from collections import defaultdict
class FakePipeline:
def __init__(self, client):
self.client = client
self.operations = []
def __getattr__(self, name):
def wrapper(*args, **kwargs):
self.operations.append((name, args, kwargs))
return self
return wrapper
def execute(self):
results = []
for name, args, kwargs in self.operations:
results.append(getattr(self.client, name)(*args, **kwargs))
self.operations.clear()
return results
class FakePubSub:
def __init__(self):
self.channels = []
self.messages = []
self.closed = False
def subscribe(self, channel):
self.channels.append(channel)
def unsubscribe(self, channel):
if channel in self.channels:
self.channels.remove(channel)
def get_message(self, timeout=1.0):
if self.messages:
return self.messages.pop(0)
return None
def close(self):
self.closed = True
class FakeRedis:
def __init__(self):
self.sorted_sets = defaultdict(dict)
self.hashes = defaultdict(dict)
self.sets = defaultdict(set)
self.published = []
self.pubsub_instance = FakePubSub()
def pipeline(self):
return FakePipeline(self)
def zadd(self, key, mapping):
self.sorted_sets[key].update(mapping)
return len(mapping)
def hset(self, key, field, value):
self.hashes[key][field] = value
return 1
def sadd(self, key, *members):
before = len(self.sets[key])
self.sets[key].update(members)
return len(self.sets[key]) - before
def zrevrange(self, key, start, stop):
items = sorted(
self.sorted_sets[key].items(),
key=lambda item: (item[1], item[0]),
reverse=True,
)
if stop == -1:
return [member for member, _ in items[start:]]
return [member for member, _ in items[start : stop + 1]]
def hget(self, key, field):
return self.hashes[key].get(field)
def zrem(self, key, *members):
removed = 0
for member in members:
if member in self.sorted_sets[key]:
del self.sorted_sets[key][member]
removed += 1
return removed
def hdel(self, key, *fields):
removed = 0
for field in fields:
if field in self.hashes[key]:
del self.hashes[key][field]
removed += 1
return removed
def smembers(self, key):
return set(self.sets[key])
def srem(self, key, member):
if member in self.sets[key]:
self.sets[key].remove(member)
return 1
return 0
def zrangebyscore(self, key, min_score, max_score):
lower = float("-inf") if min_score == "-inf" else float(min_score)
upper = float(max_score)
return [
member
for member, score in self.sorted_sets[key].items()
if lower <= score <= upper
]
def zcard(self, key):
return len(self.sorted_sets[key])
def publish(self, channel, message):
self.published.append((channel, json.loads(message)))
return 1
def pubsub(self, ignore_subscribe_messages=True):
return self.pubsub_instance

View File

@@ -1,159 +1,137 @@
import pytest
from django.test import TestCase
from rest_framework.test import APIClient
from apps.notifications.services import store as services
from apps.notifications.services import RedisNotificationStore
from apps.notifications.tests.test_services import FakeRedis
from apps.notifications.tests.fakes import FakeRedis
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
@pytest.fixture()
def fake_redis(monkeypatch):
redis = FakeRedis()
monkeypatch.setattr(services, "redis_client", redis)
return redis
class WorkspaceMembershipNotificationTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.owner = cls._create_user(1)
cls.member = cls._create_user(2)
@pytest.fixture()
def api_client():
return APIClient()
def _create_user(index: int) -> User:
@staticmethod
def _create_user(index):
return User.objects.create_user(
mobile=f"091200000{index:02d}",
password="secret123",
first_name=f"User{index}",
)
def setUp(self):
self.client = APIClient()
self.fake_redis = FakeRedis()
self.original_redis_client = services.redis_client
services.redis_client = self.fake_redis
def _notifications_for(user):
notifications, _ = RedisNotificationStore.list(
str(user.id),
paginate=False,
)
def tearDown(self):
services.redis_client = self.original_redis_client
@staticmethod
def _notifications_for(user):
notifications, _ = RedisNotificationStore.list(str(user.id), paginate=False)
return notifications
def test_workspace_create_notifies_initial_members_not_owner(self):
self.client.force_authenticate(user=self.owner)
@pytest.fixture()
def owner(db):
return _create_user(1)
@pytest.fixture()
def member(db):
return _create_user(2)
@pytest.fixture()
def another_member(db):
return _create_user(3)
@pytest.fixture()
def third_member(db):
return _create_user(4)
@pytest.fixture()
def fourth_member(db):
return _create_user(5)
def test_workspace_create_notifies_initial_members_not_owner(
fake_redis, api_client, owner, member
):
api_client.force_authenticate(user=owner)
response = api_client.post(
response = self.client.post(
"/api/workspaces/",
{
"name": "Ops",
"description": "Workspace",
"members": [
{"user_id": str(member.id), "role": WorkspaceMembership.Role.ADMIN}
{
"user_id": str(self.member.id),
"role": WorkspaceMembership.Role.ADMIN,
}
],
},
format="json",
)
assert response.status_code == 201
owner_notifications = _notifications_for(owner)
member_notifications = _notifications_for(member)
self.assertEqual(response.status_code, 201)
self.assertEqual(self._notifications_for(self.owner), [])
member_notifications = self._notifications_for(self.member)
self.assertEqual(len(member_notifications), 1)
self.assertEqual(member_notifications[0]["type"], "workspace_membership_added")
self.assertEqual(member_notifications[0]["meta"]["workspace_name"], "Ops")
self.assertEqual(
member_notifications[0]["meta"]["new_role"],
WorkspaceMembership.Role.ADMIN,
)
assert owner_notifications == []
assert len(member_notifications) == 1
assert member_notifications[0]["type"] == "workspace_membership_added"
assert member_notifications[0]["meta"]["workspace_name"] == "Ops"
assert member_notifications[0]["meta"]["new_role"] == WorkspaceMembership.Role.ADMIN
def test_workspace_membership_crud_emits_all_expected_events(self):
workspace = Workspace.objects.create(name="Design", description="", owner=self.owner)
self.client.force_authenticate(user=self.owner)
def test_workspace_membership_crud_emits_add_role_change_deactivate_and_remove(
fake_redis, api_client, owner, member
):
workspace = Workspace.objects.create(name="Design", description="", owner=owner)
api_client.force_authenticate(user=owner)
create_response = api_client.post(
create_response = self.client.post(
"/api/workspace-memberships/",
{
"workspace": str(workspace.id),
"user": str(member.id),
"user": str(self.member.id),
"role": WorkspaceMembership.Role.MEMBER,
"is_active": True,
},
format="json",
)
assert create_response.status_code == 201
self.assertEqual(create_response.status_code, 201)
membership_id = create_response.data["id"]
notifications = _notifications_for(member)
assert [item["type"] for item in notifications] == ["workspace_membership_added"]
notifications = self._notifications_for(self.member)
self.assertEqual(
[item["type"] for item in notifications],
["workspace_membership_added"],
)
role_response = api_client.patch(
role_response = self.client.patch(
f"/api/workspace-memberships/{membership_id}/",
{"role": WorkspaceMembership.Role.ADMIN},
format="json",
)
assert role_response.status_code == 200
self.assertEqual(role_response.status_code, 200)
deactivate_response = api_client.patch(
deactivate_response = self.client.patch(
f"/api/workspace-memberships/{membership_id}/",
{"is_active": False},
format="json",
)
assert deactivate_response.status_code == 200
self.assertEqual(deactivate_response.status_code, 200)
remove_response = api_client.delete(f"/api/workspace-memberships/{membership_id}/")
assert remove_response.status_code == 204
remove_response = self.client.delete(
f"/api/workspace-memberships/{membership_id}/"
)
self.assertEqual(remove_response.status_code, 204)
notifications = _notifications_for(member)
assert [item["type"] for item in notifications] == [
notifications = self._notifications_for(self.member)
self.assertEqual(
[item["type"] for item in notifications],
[
"workspace_membership_removed",
"workspace_membership_deactivated",
"workspace_membership_role_changed",
"workspace_membership_added",
]
],
)
def test_workspace_membership_update_skips_self_notifications(
fake_redis, api_client, owner
):
workspace = Workspace.objects.create(name="Product", description="", owner=owner)
def test_workspace_membership_update_skips_self_notifications(self):
workspace = Workspace.objects.create(name="Product", description="", owner=self.owner)
owner_membership = WorkspaceMembership.objects.get(
workspace=workspace,
user=owner,
user=self.owner,
is_deleted=False,
)
api_client.force_authenticate(user=owner)
self.client.force_authenticate(user=self.owner)
response = api_client.patch(
response = self.client.patch(
f"/api/workspace-memberships/{owner_membership.id}/",
{"role": WorkspaceMembership.Role.OWNER},
format="json",
)
assert response.status_code == 403
assert _notifications_for(owner) == []
self.assertEqual(response.status_code, 403)
self.assertEqual(self._notifications_for(self.owner), [])

View File

@@ -1,146 +1,22 @@
import json
from collections import defaultdict
import pytest
from django.conf import settings
from django.test import TestCase
from apps.notifications.services import store as services
from apps.notifications.services import RedisNotificationStore
from apps.notifications.tests.fakes import FakeRedis
class FakePipeline:
def __init__(self, client):
self.client = client
self.operations = []
class RedisNotificationStoreTests(TestCase):
def setUp(self):
self.fake_redis = FakeRedis()
self.original_redis_client = services.redis_client
services.redis_client = self.fake_redis
def __getattr__(self, name):
def wrapper(*args, **kwargs):
self.operations.append((name, args, kwargs))
return self
return wrapper
def execute(self):
results = []
for name, args, kwargs in self.operations:
results.append(getattr(self.client, name)(*args, **kwargs))
self.operations.clear()
return results
class FakePubSub:
def __init__(self):
self.channels = []
self.messages = []
self.closed = False
def subscribe(self, channel):
self.channels.append(channel)
def unsubscribe(self, channel):
if channel in self.channels:
self.channels.remove(channel)
def get_message(self, timeout=1.0):
if self.messages:
return self.messages.pop(0)
return None
def close(self):
self.closed = True
class FakeRedis:
def __init__(self):
self.sorted_sets = defaultdict(dict)
self.hashes = defaultdict(dict)
self.sets = defaultdict(set)
self.published = []
self.pubsub_instance = FakePubSub()
def pipeline(self):
return FakePipeline(self)
def zadd(self, key, mapping):
self.sorted_sets[key].update(mapping)
return len(mapping)
def hset(self, key, field, value):
self.hashes[key][field] = value
return 1
def sadd(self, key, *members):
before = len(self.sets[key])
self.sets[key].update(members)
return len(self.sets[key]) - before
def zrevrange(self, key, start, stop):
items = sorted(
self.sorted_sets[key].items(),
key=lambda item: (item[1], item[0]),
reverse=True,
)
if stop == -1:
return [member for member, _ in items[start:]]
return [member for member, _ in items[start : stop + 1]]
def hget(self, key, field):
return self.hashes[key].get(field)
def zrem(self, key, *members):
removed = 0
for member in members:
if member in self.sorted_sets[key]:
del self.sorted_sets[key][member]
removed += 1
return removed
def hdel(self, key, *fields):
removed = 0
for field in fields:
if field in self.hashes[key]:
del self.hashes[key][field]
removed += 1
return removed
def smembers(self, key):
return set(self.sets[key])
def srem(self, key, member):
if member in self.sets[key]:
self.sets[key].remove(member)
return 1
return 0
def zrangebyscore(self, key, min_score, max_score):
lower = float("-inf") if min_score == "-inf" else float(min_score)
upper = float(max_score)
return [
member
for member, score in self.sorted_sets[key].items()
if lower <= score <= upper
]
def zcard(self, key):
return len(self.sorted_sets[key])
def publish(self, channel, message):
self.published.append((channel, json.loads(message)))
return 1
def pubsub(self, ignore_subscribe_messages=True):
return self.pubsub_instance
@pytest.fixture()
def fake_redis(monkeypatch):
redis = FakeRedis()
monkeypatch.setattr(services, "redis_client", redis)
return redis
def test_add_publishes_notification_and_unread_count(fake_redis, settings):
settings.NOTIFICATIONS_ENABLED = True
def tearDown(self):
services.redis_client = self.original_redis_client
def test_add_publishes_notification_and_unread_count(self):
with self.settings(NOTIFICATIONS_ENABLED=True):
notification = RedisNotificationStore.add(
"user-1",
{
@@ -150,40 +26,42 @@ def test_add_publishes_notification_and_unread_count(fake_redis, settings):
},
)
assert notification["title"] == "Build finished"
assert notification["message"] == "Your deploy completed."
assert notification["level"] == "success"
assert len(fake_redis.published) == 2
channel, payload = fake_redis.published[0]
assert channel == f"{settings.NOTIFICATION_REDIS_CHANNEL_PREFIX}:user-1"
assert payload["event"] == "notification"
assert payload["data"]["notification"]["id"] == notification["id"]
assert payload["data"]["unread_count"] == 1
self.assertEqual(notification["title"], "Build finished")
self.assertEqual(notification["message"], "Your deploy completed.")
self.assertEqual(notification["level"], "success")
self.assertEqual(len(self.fake_redis.published), 2)
channel, payload = self.fake_redis.published[0]
self.assertEqual(
channel,
f"{settings.NOTIFICATION_REDIS_CHANNEL_PREFIX}:user-1",
)
self.assertEqual(payload["event"], "notification")
self.assertEqual(payload["data"]["notification"]["id"], notification["id"])
self.assertEqual(payload["data"]["unread_count"], 1)
def test_mark_seen_and_mark_all_seen_publish_sync_events(fake_redis, settings):
settings.NOTIFICATIONS_ENABLED = True
def test_mark_seen_and_mark_all_seen_publish_sync_events(self):
with self.settings(NOTIFICATIONS_ENABLED=True):
first = RedisNotificationStore.add("user-2", {"title": "First"})
second = RedisNotificationStore.add("user-2", {"title": "Second"})
fake_redis.published.clear()
RedisNotificationStore.add("user-2", {"title": "Second"})
self.fake_redis.published.clear()
payload = RedisNotificationStore.mark_seen("user-2", first["id"])
assert payload["notification_id"] == first["id"]
assert payload["deleted"] is False
assert payload["notification"]["is_seen"] is True
assert fake_redis.published[0][1]["event"] == "notification_seen"
self.assertEqual(payload["notification_id"], first["id"])
self.assertFalse(payload["deleted"])
self.assertTrue(payload["notification"]["is_seen"])
self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_seen")
fake_redis.published.clear()
self.fake_redis.published.clear()
updated = RedisNotificationStore.mark_all_seen("user-2")
assert updated == 2
assert fake_redis.published[0][1]["event"] == "notification_mark_all_read"
assert fake_redis.published[1][1]["event"] == "unread_count"
assert fake_redis.published[1][1]["data"]["unread_count"] == 0
self.assertEqual(updated, 2)
self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_mark_all_read")
self.assertEqual(self.fake_redis.published[1][1]["event"], "unread_count")
self.assertEqual(self.fake_redis.published[1][1]["data"]["unread_count"], 0)
def test_list_returns_total_count_and_filtered_notifications(fake_redis):
def test_list_returns_total_count_and_filtered_notifications(self):
RedisNotificationStore.add("user-3", {"title": "General", "type": "general"})
RedisNotificationStore.add("user-3", {"title": "Billing", "type": "billing"})
RedisNotificationStore.add("user-3", {"title": "General 2", "type": "general"})
@@ -195,6 +73,6 @@ def test_list_returns_total_count_and_filtered_notifications(fake_redis):
type_filter="general",
)
assert total_count == 2
assert len(notifications) == 1
assert notifications[0]["type"] == "general"
self.assertEqual(total_count, 2)
self.assertEqual(len(notifications), 1)
self.assertEqual(notifications[0]["type"], "general")

View File

@@ -0,0 +1,20 @@
from unittest.mock import patch
from django.conf import settings
from django.test import TestCase
from apps.notifications.tasks import cleanup_redis_notifications
class NotificationTaskTests(TestCase):
@patch("apps.notifications.tasks.RedisNotificationStore.cleanup_expired")
def test_cleanup_redis_notifications_uses_settings_retention_days(self, cleanup_expired):
cleanup_expired.return_value = 7
removed = cleanup_redis_notifications()
self.assertEqual(removed, 7)
cleanup_expired.assert_called_once_with(
retention_days=settings.NOTIFICATION_RETENTION_DAYS
)

View File

@@ -1,36 +1,38 @@
import json
import time
from datetime import timedelta
from unittest.mock import patch
import pytest
from django.test import override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from rest_framework.test import APITestCase
from apps.notifications.api import views
from apps.notifications.services import store as services
from apps.notifications.services import RedisNotificationStore
from apps.notifications.tests.test_services import FakePubSub, FakeRedis
from apps.notifications.tests.fakes import FakePubSub, FakeRedis
from apps.users.models import User
@pytest.fixture()
def fake_redis(monkeypatch):
redis = FakeRedis()
monkeypatch.setattr(services, "redis_client", redis)
return redis
class NotificationViewTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(mobile="09121111111", password="secret123")
cls.second_user = User.objects.create_user(
mobile="09122222222",
password="secret123",
)
def setUp(self):
self.fake_redis = FakeRedis()
self.original_redis_client = services.redis_client
services.redis_client = self.fake_redis
@pytest.fixture()
def user(db):
return User.objects.create_user(mobile="09121111111", password="secret123")
def tearDown(self):
services.redis_client = self.original_redis_client
@pytest.fixture()
def second_user(db):
return User.objects.create_user(mobile="09122222222", password="secret123")
def _read_sse_chunks(response, count):
@staticmethod
def _read_sse_chunks(response, count):
iterator = iter(response.streaming_content)
chunks = []
for _ in range(count):
@@ -41,67 +43,61 @@ def _read_sse_chunks(response, count):
response.close()
return chunks
def _parse_sse_data(chunk: str) -> dict:
@staticmethod
def _parse_sse_data(chunk):
for line in chunk.splitlines():
if line.startswith("data: "):
return json.loads(line.removeprefix("data: "))
raise AssertionError("SSE payload did not include data")
def test_stream_token_endpoint_returns_short_lived_token(self):
self.client.force_authenticate(user=self.user)
def test_stream_token_endpoint_returns_short_lived_token(user):
client = APIClient()
client.force_authenticate(user=user)
response = self.client.post("/api/notifications/stream-token/")
response = client.post("/api/notifications/stream-token/")
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data["token"])
self.assertGreater(response.data["expires_in"], 0)
assert response.status_code == 200
assert response.data["token"]
assert response.data["expires_in"] > 0
def test_stream_endpoint_rejects_missing_and_expired_token(self):
missing = self.client.get("/api/notifications/stream/")
self.assertEqual(missing.status_code, 401)
def test_stream_endpoint_rejects_missing_and_expired_token(user, settings):
client = APIClient()
missing = client.get("/api/notifications/stream/")
assert missing.status_code == 401
settings.NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS = 1
token = views._issue_stream_token_for_user(str(user.id))
with override_settings(NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS=1):
token = views._issue_stream_token_for_user(str(self.user.id))
time.sleep(1.1)
expired = self.client.get(f"/api/notifications/stream/?token={token}")
expired = client.get(f"/api/notifications/stream/?token={token}")
assert expired.status_code == 401
self.assertEqual(expired.status_code, 401)
def test_stream_endpoint_sends_only_current_users_notifications(
fake_redis, user, second_user, monkeypatch
):
RedisNotificationStore.add(str(user.id), {"title": "For current user"})
RedisNotificationStore.add(str(second_user.id), {"title": "For another user"})
def test_stream_endpoint_sends_only_current_users_notifications(self):
RedisNotificationStore.add(str(self.user.id), {"title": "For current user"})
RedisNotificationStore.add(str(self.second_user.id), {"title": "For another user"})
pubsub = FakePubSub()
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub))
token = views._issue_stream_token_for_user(str(user.id))
client = APIClient()
response = client.get(
with patch.object(
RedisNotificationStore,
"get_pubsub",
classmethod(lambda cls: pubsub),
):
token = views._issue_stream_token_for_user(str(self.user.id))
response = self.client.get(
f"/api/notifications/stream/?token={token}",
HTTP_ACCEPT="text/event-stream",
)
retry_line, connected_chunk = _read_sse_chunks(response, 2)
retry_line, connected_chunk = self._read_sse_chunks(response, 2)
assert response.status_code == 200
assert retry_line.startswith("retry:")
connected = _parse_sse_data(connected_chunk)
assert connected["unread_count"] == 1
assert [item["title"] for item in connected["notifications"]] == ["For current user"]
self.assertEqual(response.status_code, 200)
self.assertTrue(retry_line.startswith("retry:"))
connected = self._parse_sse_data(connected_chunk)
self.assertEqual(connected["unread_count"], 1)
self.assertEqual(
[item["title"] for item in connected["notifications"]],
["For current user"],
)
def test_stream_endpoint_emits_heartbeat(fake_redis, user, settings, monkeypatch):
def test_stream_endpoint_emits_heartbeat(self):
pubsub = FakePubSub()
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub))
settings.NOTIFICATION_SSE_HEARTBEAT_SECONDS = 1
first_now = timezone.now()
tick_values = iter(
[
@@ -118,49 +114,55 @@ def test_stream_endpoint_emits_heartbeat(fake_redis, user, settings, monkeypatch
def fake_now():
return next(tick_values, last_tick)
monkeypatch.setattr(views.timezone, "now", fake_now)
with override_settings(NOTIFICATION_SSE_HEARTBEAT_SECONDS=1):
with patch.object(
RedisNotificationStore,
"get_pubsub",
classmethod(lambda cls: pubsub),
):
with patch.object(views.timezone, "now", side_effect=fake_now):
view = views.NotificationStreamView()
stream = view._build_stream(str(user.id))
stream = view._build_stream(str(self.user.id))
chunks = [next(stream) for _ in range(4)]
stream.close()
assert "event: ping" in chunks[3]
self.assertIn("event: ping", chunks[3])
def test_notification_list_and_seen_endpoints_work(fake_redis, user):
def test_notification_list_and_seen_endpoints_work(self):
notification = RedisNotificationStore.add(
str(user.id),
str(self.user.id),
{"title": "Deploy succeeded", "type": "deploy"},
)
self.client.force_authenticate(user=self.user)
client = APIClient()
client.force_authenticate(user=user)
list_response = client.get("/api/notifications/list/?type=deploy")
assert list_response.status_code == 200
assert list_response.data["count"] == 1
assert list_response.data["unread_count"] == 1
assert list_response.data["notifications"][0]["title"] == "Deploy succeeded"
seen_response = client.post("/api/notifications/seen/", {"id": notification["id"]}, format="json")
assert seen_response.status_code == 200
assert seen_response.data["marked_read"] is True
assert seen_response.data["notification"]["is_seen"] is True
def test_notification_delete_endpoint_removes_notification(fake_redis, user):
notification = RedisNotificationStore.add(
str(user.id),
{"title": "Delete me", "type": "deploy"},
list_response = self.client.get("/api/notifications/list/?type=deploy")
self.assertEqual(list_response.status_code, 200)
self.assertEqual(list_response.data["count"], 1)
self.assertEqual(list_response.data["unread_count"], 1)
self.assertEqual(
list_response.data["notifications"][0]["title"],
"Deploy succeeded",
)
client = APIClient()
client.force_authenticate(user=user)
seen_response = self.client.post(
"/api/notifications/seen/",
{"id": notification["id"]},
format="json",
)
self.assertEqual(seen_response.status_code, 200)
self.assertTrue(seen_response.data["marked_read"])
self.assertTrue(seen_response.data["notification"]["is_seen"])
response = client.delete(f"/api/notifications/{notification['id']}/")
def test_notification_delete_endpoint_removes_notification(self):
notification = RedisNotificationStore.add(
str(self.user.id),
{"title": "Delete me", "type": "deploy"},
)
self.client.force_authenticate(user=self.user)
assert response.status_code == 200
assert response.data["deleted"] is True
assert response.data["notification_id"] == notification["id"]
assert RedisNotificationStore.get(str(user.id), notification["id"]) is None
response = self.client.delete(f"/api/notifications/{notification['id']}/")
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data["deleted"])
self.assertEqual(response.data["notification_id"], notification["id"])
self.assertIsNone(RedisNotificationStore.get(str(self.user.id), notification["id"]))

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.2.12 on 2026-04-30 12:23
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clients', '0001_initial'),
('projects', '0002_remove_projectmembership'),
('workspaces', '0007_workspacemembership_membership_ws_active_user_idx'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='project',
index=models.Index(fields=['workspace', 'is_archived', 'updated_at'], name='project_ws_arch_upd_idx'),
),
]

View File

@@ -37,6 +37,7 @@ class Project(BaseModel):
ordering = ("-updated_at", "-created_at")
indexes = [
models.Index(fields=["workspace"], name="project_workspace_idx"),
models.Index(fields=["workspace", "is_archived", "updated_at"], name="project_ws_arch_upd_idx"),
]
constraints = [
models.UniqueConstraint(

View File

@@ -0,0 +1,34 @@
from django.test import SimpleTestCase
from apps.projects.api.permissions import IsProjectManager, IsProjectMember, get_project_from_obj
class DummyWorkspace:
pass
class DummyProject:
def __init__(self):
self.workspace = DummyWorkspace()
class DummyRelatedObject:
def __init__(self):
self.project = DummyProject()
class ProjectPermissionHelperTests(SimpleTestCase):
def test_get_project_from_obj_returns_project_for_project_like_object(self):
project = DummyProject()
self.assertIs(get_project_from_obj(project), project)
def test_get_project_from_obj_returns_related_project(self):
related = DummyRelatedObject()
self.assertIs(get_project_from_obj(related), related.project)
def test_permission_messages_remain_defined(self):
self.assertTrue(IsProjectMember.message)
self.assertTrue(IsProjectManager.message)

View File

@@ -0,0 +1,97 @@
from django.test import TestCase
from rest_framework.exceptions import PermissionDenied, ValidationError
from apps.clients.models import Client
from apps.projects.models import Project
from apps.projects.services.projects import (
create_project,
toggle_project_archive,
update_project,
)
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
class ProjectServiceTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(mobile="09120000041", password="secret123")
cls.member = User.objects.create_user(mobile="09120000042", password="secret123")
cls.outsider = User.objects.create_user(mobile="09120000043", password="secret123")
cls.workspace = Workspace.objects.create(name="Projects Services", owner=cls.owner)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
cls.account_client = Client.objects.create(workspace=cls.workspace, name="Acme")
def test_create_project_creates_workspace_shared_project(self):
project = create_project(
user=self.member,
workspace=self.workspace,
name="Alpha",
client=self.account_client,
description="Desc",
color="#123456",
)
self.assertEqual(project.name, "Alpha")
self.assertEqual(project.client, self.account_client)
self.assertEqual(project.description, "Desc")
def test_create_project_rejects_non_member(self):
with self.assertRaises(PermissionDenied):
create_project(self.outsider, self.workspace, "Alpha")
def test_create_project_rejects_duplicate_name(self):
Project.objects.create(workspace=self.workspace, name="Alpha")
with self.assertRaises(ValidationError) as exc:
create_project(self.owner, self.workspace, "Alpha")
self.assertIn("name", exc.exception.detail)
def test_update_project_updates_client_and_fields(self):
second_client = Client.objects.create(workspace=self.workspace, name="Globex")
project = Project.objects.create(
workspace=self.workspace,
name="Alpha",
client=self.account_client,
)
updated = update_project(
project,
name="Beta",
client=str(second_client.id),
description="Updated",
color="#abcdef",
)
self.assertEqual(updated.name, "Beta")
self.assertEqual(updated.client, second_client)
self.assertEqual(updated.description, "Updated")
self.assertEqual(updated.color, "#abcdef")
def test_update_project_rejects_duplicate_name(self):
Project.objects.create(workspace=self.workspace, name="Beta")
project = Project.objects.create(
workspace=self.workspace,
name="Alpha",
client=self.account_client,
)
with self.assertRaises(ValidationError) as exc:
update_project(project, name="Beta", client=str(self.account_client.id))
self.assertIn("name", exc.exception.detail)
def test_toggle_project_archive_flips_state(self):
project = Project.objects.create(workspace=self.workspace, name="Alpha")
toggle_project_archive(project)
self.assertTrue(Project.objects.get(id=project.id).is_archived)
toggle_project_archive(project)
self.assertFalse(Project.objects.get(id=project.id).is_archived)

View File

@@ -1,5 +1,4 @@
import pytest
from rest_framework.test import APIClient
from rest_framework.test import APITestCase
from apps.clients.models import Client
from apps.projects.models import Project
@@ -7,69 +6,65 @@ from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
@pytest.fixture()
def api_client():
return APIClient()
@pytest.fixture()
def owner(db):
return User.objects.create_user(mobile="09121110001", password="secret123", first_name="Owner")
@pytest.fixture()
def workspace(owner):
return Workspace.objects.create(name="Projects", owner=owner)
@pytest.fixture()
def member(db, workspace):
user = User.objects.create_user(mobile="09121110002", password="secret123", first_name="Member")
class ProjectViewTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(
mobile="09121110001",
password="secret123",
first_name="Owner",
)
cls.workspace = Workspace.objects.create(name="Projects", owner=cls.owner)
cls.member = User.objects.create_user(
mobile="09121110002",
password="secret123",
first_name="Member",
)
WorkspaceMembership.objects.create(
workspace=workspace,
user=user,
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
return user
cls.first_client = Client.objects.create(workspace=cls.workspace, name="Acme")
cls.second_client = Client.objects.create(workspace=cls.workspace, name="Globex")
cls.third_client = Client.objects.create(workspace=cls.workspace, name="Initech")
Project.objects.create(
workspace=cls.workspace,
client=cls.first_client,
name="Alpha",
)
Project.objects.create(
workspace=cls.workspace,
client=cls.second_client,
name="Beta",
)
Project.objects.create(
workspace=cls.workspace,
client=cls.third_client,
name="Gamma",
)
def test_project_list_supports_multi_client_filter(self):
self.client.force_authenticate(user=self.member)
@pytest.fixture()
def clients(workspace):
first = Client.objects.create(workspace=workspace, name="Acme")
second = Client.objects.create(workspace=workspace, name="Globex")
third = Client.objects.create(workspace=workspace, name="Initech")
return first, second, third
@pytest.fixture()
def projects(workspace, clients):
first, second, third = clients
return [
Project.objects.create(workspace=workspace, client=first, name="Alpha"),
Project.objects.create(workspace=workspace, client=second, name="Beta"),
Project.objects.create(workspace=workspace, client=third, name="Gamma"),
]
def test_project_list_supports_multi_client_filter(api_client, member, workspace, clients, projects):
api_client.force_authenticate(user=member)
first, second, _ = clients
response = api_client.get(
response = self.client.get(
"/api/projects/",
[
("workspace", str(workspace.id)),
("clients", str(first.id)),
("clients", str(second.id)),
("workspace", str(self.workspace.id)),
("clients", str(self.first_client.id)),
("clients", str(self.second_client.id)),
],
)
assert response.status_code == 200
self.assertEqual(response.status_code, 200)
items = (
response.data
if isinstance(response.data, list)
else response.data.get("results") or response.data.get("items", [])
)
result_ids = {str(item["client"]["id"]) for item in items}
assert result_ids == {str(first.id), str(second.id)}
self.assertEqual(
result_ids,
{str(self.first_client.id), str(self.second_client.id)},
)

View File

@@ -23,6 +23,10 @@ from apps.reports.services import (
load_report_filters,
)
from apps.reports.tasks import generate_report_export_task
from core.services.cache import CACHE_NAMESPACE_REPORTS, get_or_set_cache_payload
REPORT_CACHE_TTL_SECONDS = 90
class ReportChartView(APIView):
@@ -30,7 +34,17 @@ class ReportChartView(APIView):
@extend_schema(responses=dict)
def get(self, request):
return Response(build_chart_report(request.user, request.query_params))
workspace_id = request.query_params.get("workspace")
payload = get_or_set_cache_payload(
CACHE_NAMESPACE_REPORTS,
ttl_seconds=REPORT_CACHE_TTL_SECONDS,
builder=lambda: build_chart_report(request.user, request.query_params),
resource="chart",
user_id=request.user.id,
workspace_id=workspace_id,
params=request.query_params,
)
return Response(payload)
class ReportTableView(APIView):
@@ -38,7 +52,17 @@ class ReportTableView(APIView):
@extend_schema(responses=dict)
def get(self, request):
return Response(build_table_report(request.user, request.query_params))
workspace_id = request.query_params.get("workspace")
payload = get_or_set_cache_payload(
CACHE_NAMESPACE_REPORTS,
ttl_seconds=REPORT_CACHE_TTL_SECONDS,
builder=lambda: build_table_report(request.user, request.query_params),
resource="table",
user_id=request.user.id,
workspace_id=workspace_id,
params=request.query_params,
)
return Response(payload)
class ReportDayDetailsView(APIView):
@@ -46,7 +70,17 @@ class ReportDayDetailsView(APIView):
@extend_schema(responses=dict)
def get(self, request):
return Response(build_day_details_report(request.user, request.query_params))
workspace_id = request.query_params.get("workspace")
payload = get_or_set_cache_payload(
CACHE_NAMESPACE_REPORTS,
ttl_seconds=REPORT_CACHE_TTL_SECONDS,
builder=lambda: build_day_details_report(request.user, request.query_params),
resource="day-details",
user_id=request.user.id,
workspace_id=workspace_id,
params=request.query_params,
)
return Response(payload)
class ReportExportJobViewSet(

View File

@@ -0,0 +1,105 @@
from datetime import date
from types import SimpleNamespace
from unittest.mock import patch
from django.core.files.base import ContentFile
from django.test import TestCase
from rest_framework.test import APIClient
from apps.reports.models import ReportExportJob
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
class ReportExportApiTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(
mobile="09126660001",
password="secret123",
first_name="Owner",
)
cls.admin = User.objects.create_user(
mobile="09126660002",
password="secret123",
first_name="Admin",
)
cls.workspace = Workspace.objects.create(name="Exports", owner=cls.owner)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.admin,
role=WorkspaceMembership.Role.ADMIN,
is_active=True,
)
def setUp(self):
self.client = APIClient()
def test_create_export_job_enqueues_background_task(self):
self.client.force_authenticate(user=self.owner)
filters = SimpleNamespace(
workspace=self.workspace,
period="this_month",
from_date=date(2026, 4, 1),
to_date=date(2026, 4, 30),
user_id=None,
client_id=None,
project_id=None,
tag_ids=[],
)
with patch("apps.reports.api.views.load_report_filters", return_value=filters):
with patch("apps.reports.api.views.generate_report_export_task.delay") as delay:
response = self.client.post(
"/api/reports/exports/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"export_type": "excel",
"language": "en",
},
format="json",
)
self.assertEqual(response.status_code, 202)
self.assertEqual(ReportExportJob.objects.count(), 1)
delay.assert_called_once()
def test_list_only_returns_requesting_users_jobs(self):
own_job = ReportExportJob.objects.create(
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={"workspace": str(self.workspace.id)},
)
ReportExportJob.objects.create(
requesting_user=self.admin,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.PDF,
filters={"workspace": str(self.workspace.id)},
)
self.client.force_authenticate(user=self.owner)
response = self.client.get("/api/reports/exports/")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["id"], str(own_job.id))
def test_download_returns_completed_file(self):
job = ReportExportJob.objects.create(
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.EXCEL,
status=ReportExportJob.Status.COMPLETED,
filters={"workspace": str(self.workspace.id)},
file_name="report.xlsx",
)
job.file.save("reports/exports/report.xlsx", ContentFile(b"content"), save=False)
job.save(update_fields=["file", "updated_at"])
self.client.force_authenticate(user=self.owner)
response = self.client.get(f"/api/reports/exports/{job.id}/download/")
self.assertEqual(response.status_code, 200)
self.assertIn("attachment; filename=", response["Content-Disposition"])

View File

@@ -0,0 +1,104 @@
from io import BytesIO
from django.test import TestCase
from openpyxl import load_workbook
from apps.reports.services.export_i18n import build_export_locale
from apps.reports.services.exporters import build_excel_report, build_pdf_report
def make_report_data(*, user_name="Owner User", mobile="09129990001", hourly_rate=None):
return {
"scope": {
"workspace": {"name": "Exports", "thumbnail_path": None},
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": {"name": user_name, "mobile": mobile} if user_name else None,
},
"summary": {
"total_duration": "02:00:00",
"billable_duration": "02:00:00",
"non_billable_duration": "00:00:00",
"income_totals": [{"amount": "30.00", "currency": "USD"}],
},
"days": [
{
"date": "2026-04-12",
"billable_duration": "02:00:00",
"non_billable_duration": "00:00:00",
"total_duration": "02:00:00",
"latest_hourly_rate": hourly_rate,
"income_totals": [{"amount": "30.00", "currency": "USD"}],
}
],
"clients": [
{
"name": "Acme",
"billable_duration": "02:00:00",
"non_billable_duration": "00:00:00",
"total_duration": "02:00:00",
"income_totals": [{"amount": "30.00", "currency": "USD"}],
}
],
"projects": [],
"tags": [],
}
class ReportExporterTests(TestCase):
def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self):
locale = build_export_locale("en")
report_data = make_report_data(
hourly_rate={"amount": "15.00", "currency": "USD"},
)
per_user_reports = [
make_report_data(user_name="Owner User", mobile="09129990001"),
make_report_data(user_name="Team Mate", mobile="09129990002"),
]
workbook = load_workbook(
BytesIO(
build_excel_report(
report_data=report_data,
locale=locale,
per_user_reports=per_user_reports,
)
)
)
self.assertEqual(workbook.sheetnames[0], "Overall Report")
self.assertIn("Owner User", workbook.sheetnames[1])
self.assertIn("Team Mate", workbook.sheetnames[2])
worksheet = workbook.active
values = list(worksheet.iter_rows(values_only=True))
self.assertTrue(any(row[:2] == ("User", "Owner User") for row in values if row))
self.assertTrue(any(row[:2] == ("Mobile", "09129990001") for row in values if row))
daily_header = next(row[:6] for row in values if row and row[0] == "Date")
self.assertEqual(
daily_header,
(
"Date",
"Billable hours",
"Non-billable hours",
"Total hours",
"Hourly rate",
"Income",
),
)
daily_row = next(row[:6] for row in values if row and row[0] == "2026/04/12")
self.assertEqual(daily_row[4], "15 USD")
def test_pdf_export_supports_persian_locale(self):
locale = build_export_locale("fa")
report_data = make_report_data(
hourly_rate={"amount": "15.00", "currency": "USD"},
)
content = build_pdf_report(report_data=report_data, locale=locale)
self.assertEqual(content[:4], b"%PDF")

View File

@@ -1,126 +1,41 @@
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from unittest.mock import patch
import pytest
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.test import TestCase
from django.utils import timezone
from openpyxl import load_workbook
from apps.notifications.services import store as notification_store
from apps.reports.models import ReportExportJob
from apps.reports.tasks import cleanup_expired_report_exports_task, generate_report_export_task
from apps.time_entries.models import TimeEntry
from apps.reports.tasks import (
cleanup_expired_report_exports_task,
generate_report_export_task,
)
from apps.users.models import User
from apps.workspaces.models import Workspace
class FakeRedis:
def pipeline(self):
return self
def zadd(self, *args, **kwargs):
return self
def hset(self, *args, **kwargs):
return self
def sadd(self, *args, **kwargs):
return self
def execute(self):
return []
def publish(self, *args, **kwargs):
return None
def zrevrange(self, *args, **kwargs):
return []
def hget(self, *args, **kwargs):
return None
def zrem(self, *args, **kwargs):
return 1
def hdel(self, *args, **kwargs):
return 1
def zcard(self, *args, **kwargs):
return 0
def smembers(self, *args, **kwargs):
return set()
def srem(self, *args, **kwargs):
return 1
@pytest.fixture()
def fake_redis(monkeypatch):
redis = FakeRedis()
monkeypatch.setattr(notification_store, "redis_client", redis)
return redis
@pytest.fixture()
def owner(db):
return User.objects.create_user(mobile="09129990001", password="secret123", first_name="Owner", last_name="User")
@pytest.fixture()
def teammate(db):
return User.objects.create_user(mobile="09129990002", password="secret123", first_name="Team", last_name="Mate")
@pytest.fixture()
def workspace(owner, teammate):
workspace = Workspace.objects.create(name="Exports", owner=owner)
workspace.memberships.create(user=teammate, role="member", is_active=True)
return workspace
@pytest.fixture()
def time_entry(workspace, owner):
return TimeEntry.objects.create(
workspace=workspace,
user=owner,
description="Export row",
start_time="2026-04-12T08:00:00+03:30",
end_time="2026-04-12T10:00:00+03:30",
duration=timedelta(hours=2),
is_billable=True,
hourly_rate=Decimal("15.00"),
currency="USD",
class ReportTaskTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(
mobile="09129990001",
password="secret123",
first_name="Owner",
last_name="User",
)
cls.workspace = Workspace.objects.create(name="Exports", owner=cls.owner)
@pytest.fixture()
def teammate_entry(workspace, teammate):
return TimeEntry.objects.create(
workspace=workspace,
user=teammate,
description="Team row",
start_time="2026-04-13T08:00:00+03:30",
end_time="2026-04-13T09:00:00+03:30",
duration=timedelta(hours=1),
is_billable=False,
currency="USD",
)
def test_generate_export_creates_file_and_marks_job_complete(fake_redis, workspace, owner, time_entry):
def test_generate_excel_export_marks_job_complete_and_sends_notification(self):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={
"workspace": str(workspace.id),
"workspace": str(self.workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(owner.id),
"user": str(self.owner.id),
"client": None,
"project": None,
"tags": [],
@@ -128,107 +43,34 @@ def test_generate_export_creates_file_and_marks_job_complete(fake_redis, workspa
},
)
with patch("apps.reports.tasks.build_table_report", return_value={"scope": {}, "summary": {}, "days": [], "clients": [], "projects": [], "tags": []}) as build_table_report:
with patch("apps.reports.tasks.build_user_scoped_table_reports", return_value=[]) as build_user_reports:
with patch("apps.reports.tasks.build_excel_report", return_value=b"excel-content") as build_excel_report:
with patch("apps.reports.tasks.RedisNotificationStore.add") as notify:
generate_report_export_task(str(job.id))
job.refresh_from_db()
self.assertEqual(job.status, ReportExportJob.Status.COMPLETED)
self.assertTrue(bool(job.file))
self.assertTrue(default_storage.exists(job.file.name))
build_table_report.assert_called_once()
build_user_reports.assert_called_once()
build_excel_report.assert_called_once()
notify.assert_called_once()
self.assertEqual(notify.call_args.args[0], str(self.owner.id))
self.assertEqual(notify.call_args.args[1]["type"], "report_export_ready")
assert job.status == ReportExportJob.Status.COMPLETED
assert bool(job.file)
assert default_storage.exists(job.file.name)
def test_generate_excel_export_adds_per_user_sheets_for_all_users_scope(
fake_redis,
workspace,
owner,
teammate,
time_entry,
teammate_entry,
):
def test_generate_pdf_export_failure_marks_job_failed_and_notifies(self):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={
"workspace": str(workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": None,
"client": None,
"project": None,
"tags": [],
"language": "en",
},
)
generate_report_export_task(str(job.id))
job.refresh_from_db()
workbook = load_workbook(BytesIO(job.file.read()))
assert workbook.sheetnames[0] == "Overall Report"
assert any("Owner User" in sheet for sheet in workbook.sheetnames[1:])
assert any("Team Mate" in sheet for sheet in workbook.sheetnames[1:])
assert len(workbook.sheetnames) == 3
def test_generate_excel_export_includes_daily_rate_column_and_split_user_meta(
fake_redis,
workspace,
owner,
time_entry,
):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={
"workspace": str(workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(owner.id),
"client": None,
"project": None,
"tags": [],
"language": "en",
},
)
generate_report_export_task(str(job.id))
job.refresh_from_db()
workbook = load_workbook(BytesIO(job.file.read()))
worksheet = workbook.active
values = list(worksheet.iter_rows(values_only=True))
assert any(row[:2] == ("User", "Owner User") for row in values if row)
assert any(row[:2] == ("Mobile", "09129990001") for row in values if row)
daily_header = next(row[:6] for row in values if row and row[0] == "Date")
assert daily_header == (
"Date",
"Billable hours",
"Non-billable hours",
"Total hours",
"Hourly rate",
"Income",
)
daily_row = next(row[:6] for row in values if row and row[0] == "2026/04/12")
assert daily_row[4] == "15 USD"
def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owner, time_entry):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.PDF,
filters={
"workspace": str(workspace.id),
"workspace": str(self.workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(owner.id),
"user": str(self.owner.id),
"client": None,
"project": None,
"tags": [],
@@ -236,17 +78,22 @@ def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owne
},
)
with patch("apps.reports.tasks.build_table_report", return_value={"scope": {}, "summary": {}, "days": [], "clients": [], "projects": [], "tags": []}):
with patch("apps.reports.tasks.build_pdf_report", side_effect=RuntimeError("boom")):
with patch("apps.reports.tasks.RedisNotificationStore.add") as notify:
with self.assertRaises(RuntimeError):
generate_report_export_task(str(job.id))
job.refresh_from_db()
self.assertEqual(job.status, ReportExportJob.Status.FAILED)
self.assertEqual(job.error_message, "boom")
notify.assert_called_once()
self.assertEqual(notify.call_args.args[1]["type"], "report_export_failed")
assert job.status == ReportExportJob.Status.COMPLETED
assert job.file.read(4) == b"%PDF"
def test_cleanup_expires_and_removes_files(fake_redis, workspace, owner):
def test_cleanup_expires_and_removes_files(self):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.EXCEL,
status=ReportExportJob.Status.COMPLETED,
filters={},
@@ -259,6 +106,6 @@ def test_cleanup_expires_and_removes_files(fake_redis, workspace, owner):
removed = cleanup_expired_report_exports_task()
job.refresh_from_db()
assert removed == 1
assert job.status == ReportExportJob.Status.EXPIRED
assert not default_storage.exists(file_name)
self.assertEqual(removed, 1)
self.assertEqual(job.status, ReportExportJob.Status.EXPIRED)
self.assertFalse(default_storage.exists(file_name))

View File

@@ -1,8 +1,9 @@
from datetime import date, timedelta
from decimal import Decimal
from unittest.mock import patch
import pytest
from rest_framework.test import APIClient
from django.core.cache import cache
from rest_framework.test import APITestCase
from apps.clients.models import Client
from apps.projects.models import Project
@@ -12,55 +13,53 @@ from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
@pytest.fixture()
def api_client():
return APIClient()
class ReportViewTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(
mobile="09128880001",
password="secret123",
first_name="Owner",
)
cls.admin = User.objects.create_user(
mobile="09128880002",
password="secret123",
first_name="Admin",
)
cls.member = User.objects.create_user(
mobile="09128880003",
password="secret123",
first_name="Member",
)
cls.workspace = Workspace.objects.create(name="Reports", owner=cls.owner)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.admin,
role=WorkspaceMembership.Role.ADMIN,
is_active=True,
)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
cls.client_obj = Client.objects.create(workspace=cls.workspace, name="Acme")
cls.project = Project.objects.create(
workspace=cls.workspace,
name="Website",
client=cls.client_obj,
)
cls.tag = Tag.objects.create(
workspace=cls.workspace,
name="Design",
color="#ffffff",
)
@pytest.fixture()
def owner(db):
return User.objects.create_user(mobile="09128880001", password="secret123", first_name="Owner")
@pytest.fixture()
def admin(db):
return User.objects.create_user(mobile="09128880002", password="secret123", first_name="Admin")
@pytest.fixture()
def member(db):
return User.objects.create_user(mobile="09128880003", password="secret123", first_name="Member")
@pytest.fixture()
def workspace(owner, admin, member):
workspace = Workspace.objects.create(name="Reports", owner=owner)
WorkspaceMembership.objects.create(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True)
WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True)
return workspace
@pytest.fixture()
def client(workspace):
return Client.objects.create(workspace=workspace, name="Acme")
@pytest.fixture()
def project(workspace, client):
return Project.objects.create(workspace=workspace, name="Website", client=client)
@pytest.fixture()
def tag(workspace):
return Tag.objects.create(workspace=workspace, name="Design", color="#ffffff")
@pytest.fixture()
def time_entries(workspace, owner, member, project, tag):
entry_owner = TimeEntry.objects.create(
workspace=workspace,
user=owner,
project=project,
workspace=cls.workspace,
user=cls.owner,
project=cls.project,
description="Owner work",
start_time="2026-04-10T08:00:00+03:30",
end_time="2026-04-10T10:00:00+03:30",
@@ -69,11 +68,12 @@ def time_entries(workspace, owner, member, project, tag):
hourly_rate=Decimal("25.00"),
currency="USD",
)
entry_owner.tags.add(tag)
entry_owner.tags.add(cls.tag)
entry_member = TimeEntry.objects.create(
workspace=workspace,
user=member,
project=project,
workspace=cls.workspace,
user=cls.member,
project=cls.project,
description="Member work",
start_time="2026-04-11T09:00:00+03:30",
end_time="2026-04-11T10:00:00+03:30",
@@ -81,44 +81,51 @@ def time_entries(workspace, owner, member, project, tag):
is_billable=False,
currency="USD",
)
entry_member.tags.add(tag)
return [entry_owner, entry_member]
entry_member.tags.add(cls.tag)
def setUp(self):
cache.clear()
def test_member_only_sees_own_chart_report(api_client, member, workspace, time_entries):
api_client.force_authenticate(user=member)
def test_member_only_sees_own_chart_report(self):
self.client.force_authenticate(user=self.member)
response = api_client.get(
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/chart/",
{"workspace": str(workspace.id), "period": "this_month"},
{"workspace": str(self.workspace.id), "period": "this_month"},
)
assert response.status_code == 200
assert response.data["summary"]["total_duration"] == "01:00:00"
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "01:00:00")
def test_admin_can_request_combined_table_report(self):
self.client.force_authenticate(user=self.admin)
def test_admin_can_request_combined_table_report(api_client, admin, workspace, time_entries):
api_client.force_authenticate(user=admin)
response = api_client.get(
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/table/",
{"workspace": str(workspace.id), "period": "this_month"},
{"workspace": str(self.workspace.id), "period": "this_month"},
)
assert response.status_code == 200
assert response.data["summary"]["total_duration"] == "03:00:00"
assert len(response.data["days"]) == 2
assert response.data["days"][0]["latest_hourly_rate"] is None
assert response.data["days"][1]["latest_hourly_rate"] is None
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
self.assertEqual(len(response.data["days"]), 2)
self.assertIsNone(response.data["days"][0]["latest_hourly_rate"])
self.assertIsNone(response.data["days"][1]["latest_hourly_rate"])
def test_daily_rate_uses_latest_billable_entry_snapshot(api_client, owner, workspace, project):
api_client.force_authenticate(user=owner)
def test_daily_rate_uses_latest_billable_entry_snapshot(self):
self.client.force_authenticate(user=self.owner)
TimeEntry.objects.create(
workspace=workspace,
user=owner,
project=project,
workspace=self.workspace,
user=self.owner,
project=self.project,
description="Morning work",
start_time="2026-04-15T08:00:00+03:30",
end_time="2026-04-15T09:00:00+03:30",
@@ -128,9 +135,9 @@ def test_daily_rate_uses_latest_billable_entry_snapshot(api_client, owner, works
currency="USD",
)
TimeEntry.objects.create(
workspace=workspace,
user=owner,
project=project,
workspace=self.workspace,
user=self.owner,
project=self.project,
description="Later work",
start_time="2026-04-15T13:00:00+03:30",
end_time="2026-04-15T15:00:00+03:30",
@@ -140,42 +147,52 @@ def test_daily_rate_uses_latest_billable_entry_snapshot(api_client, owner, works
currency="USD",
)
response = api_client.get(
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/table/",
{"workspace": str(workspace.id), "period": "this_month", "user": str(owner.id)},
{
"workspace": str(self.workspace.id),
"period": "this_month",
"user": str(self.owner.id),
},
)
assert response.status_code == 200
assert response.data["days"][0]["latest_hourly_rate"] == {
"amount": "35.00",
"currency": "USD",
}
self.assertEqual(response.status_code, 200)
target_day = next(day for day in response.data["days"] if day["date"] == "2026-04-15")
self.assertEqual(
target_day["latest_hourly_rate"],
{"amount": "35.00", "currency": "USD"},
)
def test_custom_period_longer_than_31_days_is_rejected(self):
self.client.force_authenticate(user=self.owner)
def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace):
api_client.force_authenticate(user=owner)
response = api_client.get(
response = self.client.get(
"/api/reports/chart/",
{
"workspace": str(workspace.id),
"workspace": str(self.workspace.id),
"period": "period",
"from_date": "2026-01-01",
"to_date": "2026-02-15",
},
)
assert response.status_code == 400
self.assertEqual(response.status_code, 400)
def test_persian_this_month_uses_jalali_month_bounds(self):
self.client.force_authenticate(user=self.owner)
def test_persian_this_month_uses_jalali_month_bounds(api_client, owner, workspace, project, monkeypatch):
api_client.force_authenticate(user=owner)
monkeypatch.setattr("apps.reports.services.aggregation.timezone.localdate", lambda: date(2026, 4, 27))
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 27),
):
TimeEntry.objects.create(
workspace=workspace,
user=owner,
project=project,
workspace=self.workspace,
user=self.owner,
project=self.project,
description="Previous jalali month",
start_time="2026-04-20T08:00:00+03:30",
end_time="2026-04-20T09:00:00+03:30",
@@ -184,9 +201,9 @@ def test_persian_this_month_uses_jalali_month_bounds(api_client, owner, workspac
currency="USD",
)
TimeEntry.objects.create(
workspace=workspace,
user=owner,
project=project,
workspace=self.workspace,
user=self.owner,
project=self.project,
description="Current jalali month",
start_time="2026-04-21T08:00:00+03:30",
end_time="2026-04-21T10:00:00+03:30",
@@ -195,11 +212,51 @@ def test_persian_this_month_uses_jalali_month_bounds(api_client, owner, workspac
currency="USD",
)
response = api_client.get(
response = self.client.get(
"/api/reports/table/",
{"workspace": str(workspace.id), "period": "this_month", "language": "fa"},
{
"workspace": str(self.workspace.id),
"period": "this_month",
"language": "fa",
},
)
assert response.status_code == 200
assert response.data["summary"]["total_duration"] == "02:00:00"
assert response.data["scope"]["from_date"] == "2026-04-21"
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "02:00:00")
self.assertEqual(response.data["scope"]["from_date"], "2026-04-21")
def test_table_report_cache_stays_until_time_entry_invalidation(self):
self.client.force_authenticate(user=self.owner)
url = "/api/reports/table/"
params = {"workspace": str(self.workspace.id), "period": "this_month"}
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
first_response = self.client.get(url, params)
self.assertEqual(first_response.status_code, 200)
self.assertEqual(first_response.data["summary"]["total_duration"], "03:00:00")
member_entry = TimeEntry.objects.get(description="Member work")
TimeEntry.objects.filter(id=member_entry.id).update(duration=timedelta(hours=5))
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
cached_response = self.client.get(url, params)
self.assertEqual(cached_response.status_code, 200)
self.assertEqual(cached_response.data["summary"]["total_duration"], "03:00:00")
member_entry.refresh_from_db()
member_entry.description = "Member work updated"
member_entry.save(update_fields=["description"])
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
fresh_response = self.client.get(url, params)
self.assertEqual(fresh_response.status_code, 200)
self.assertEqual(fresh_response.data["summary"]["total_duration"], "07:00:00")

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,59 @@
from django.test import TestCase
from rest_framework.exceptions import PermissionDenied, ValidationError
from apps.tags.models import Tag
from apps.tags.services.tags import create_tag, update_tag
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
class TagServiceTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(mobile="09120000021", password="secret123")
cls.member = User.objects.create_user(mobile="09120000022", password="secret123")
cls.outsider = User.objects.create_user(mobile="09120000023", password="secret123")
cls.workspace = Workspace.objects.create(name="Tags", owner=cls.owner)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
def test_create_tag_creates_record_for_workspace_member(self):
tag = create_tag(self.member, self.workspace.id, "Urgent", "#111111")
self.assertEqual(tag.name, "Urgent")
self.assertEqual(tag.color, "#111111")
self.assertEqual(tag.created_by, self.member)
def test_create_tag_rejects_non_member(self):
with self.assertRaises(PermissionDenied):
create_tag(self.outsider, self.workspace.id, "Urgent")
def test_create_tag_rejects_duplicate_name(self):
Tag.objects.create(workspace=self.workspace, name="Urgent")
with self.assertRaises(ValidationError) as exc:
create_tag(self.owner, self.workspace.id, "Urgent")
self.assertIn("name", exc.exception.detail)
def test_update_tag_changes_requested_fields(self):
tag = Tag.objects.create(workspace=self.workspace, name="Urgent", color="#000000")
updated = update_tag(tag, name="Later", color="#222222")
self.assertEqual(updated.name, "Later")
self.assertEqual(updated.color, "#222222")
def test_update_tag_rejects_duplicate_name(self):
Tag.objects.create(workspace=self.workspace, name="Existing")
tag = Tag.objects.create(workspace=self.workspace, name="Urgent")
with self.assertRaises(ValidationError) as exc:
update_tag(tag, name="Existing")
self.assertIn("name", exc.exception.detail)

View File

@@ -0,0 +1,136 @@
from rest_framework.test import APITestCase
from apps.tags.models import Tag
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
class TagViewTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(mobile="09120000031", password="secret123")
cls.admin = User.objects.create_user(mobile="09120000032", password="secret123")
cls.second_admin = User.objects.create_user(mobile="09120000033", password="secret123")
cls.member = User.objects.create_user(mobile="09120000034", password="secret123")
cls.guest = User.objects.create_user(mobile="09120000035", password="secret123")
cls.outsider = User.objects.create_user(mobile="09120000036", password="secret123")
cls.workspace = Workspace.objects.create(name="Tags API", owner=cls.owner)
for user, role in (
(cls.admin, WorkspaceMembership.Role.ADMIN),
(cls.second_admin, WorkspaceMembership.Role.ADMIN),
(cls.member, WorkspaceMembership.Role.MEMBER),
(cls.guest, WorkspaceMembership.Role.GUEST),
):
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=user,
role=role,
is_active=True,
)
cls.other_workspace = Workspace.objects.create(name="Elsewhere", owner=cls.outsider)
cls.visible_tag = Tag.objects.create(workspace=cls.workspace, name="Visible", color="#111111")
cls.hidden_tag = Tag.objects.create(workspace=cls.other_workspace, name="Hidden", color="#222222")
cls.admin_owned_tag = Tag.objects.create(
workspace=cls.workspace,
name="Admin Tag",
color="#333333",
created_by=cls.admin,
updated_by=cls.admin,
)
def test_list_only_returns_tags_for_member_workspaces(self):
self.client.force_authenticate(user=self.member)
response = self.client.get("/api/tags/")
self.assertEqual(response.status_code, 200)
results = (
response.data
if isinstance(response.data, list)
else response.data.get("results")
or response.data.get("items")
or []
)
names = {item["name"] for item in results}
self.assertIn("Visible", names)
self.assertNotIn("Hidden", names)
def test_member_can_create_tag(self):
self.client.force_authenticate(user=self.member)
response = self.client.post(
"/api/tags/",
{
"workspace_id": str(self.workspace.id),
"name": "Created",
"color": "#123456",
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["name"], "Created")
def test_guest_cannot_create_tag(self):
self.client.force_authenticate(user=self.guest)
response = self.client.post(
"/api/tags/",
{
"workspace_id": str(self.workspace.id),
"name": "Created",
},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_member_cannot_update_tag(self):
self.client.force_authenticate(user=self.member)
response = self.client.patch(
f"/api/tags/{self.visible_tag.id}/",
{"name": "Updated"},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_admin_can_update_tag(self):
self.client.force_authenticate(user=self.admin)
response = self.client.patch(
f"/api/tags/{self.visible_tag.id}/",
{"name": "Updated"},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["name"], "Updated")
def test_admin_can_delete_only_tag_they_created(self):
self.client.force_authenticate(user=self.second_admin)
forbidden = self.client.delete(f"/api/tags/{self.admin_owned_tag.id}/")
self.assertEqual(forbidden.status_code, 403)
self.client.force_authenticate(user=self.admin)
allowed = self.client.delete(f"/api/tags/{self.admin_owned_tag.id}/")
self.assertEqual(allowed.status_code, 204)
self.assertTrue(Tag.all_objects.get(id=self.admin_owned_tag.id).is_deleted)
def test_owner_can_delete_any_tag(self):
tag = Tag.objects.create(
workspace=self.workspace,
name="Owner Delete",
created_by=self.admin,
updated_by=self.admin,
)
self.client.force_authenticate(user=self.owner)
response = self.client.delete(f"/api/tags/{tag.id}/")
self.assertEqual(response.status_code, 204)
self.assertTrue(Tag.all_objects.get(id=tag.id).is_deleted)

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.12 on 2026-04-30 12:23
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0003_project_project_ws_arch_upd_idx'),
('tags', '0001_initial'),
('time_entries', '0001_initial'),
('workspaces', '0007_workspacemembership_membership_ws_active_user_idx'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='timeentry',
index=models.Index(fields=['workspace', 'user', 'start_time'], name='time_entry_ws_user_start_idx'),
),
]

View File

@@ -62,6 +62,7 @@ class TimeEntry(BaseModel):
models.Index(fields=["project"], name="time_entry_project_idx"),
models.Index(fields=["start_time"], name="time_entry_start_idx"),
models.Index(fields=["workspace", "start_time"], name="time_entry_workspace_start_idx"),
models.Index(fields=["workspace", "user", "start_time"], name="time_entry_ws_user_start_idx"),
]
constraints = [
models.UniqueConstraint(

View File

@@ -0,0 +1 @@

View File

@@ -1,5 +1,7 @@
from datetime import datetime
from django.test import TestCase
from apps.clients.models import Client
from apps.projects.models import Project
from apps.tags.models import Tag
@@ -12,17 +14,34 @@ from apps.workspaces.models import Workspace
def make_aware(year, month, day, hour=9, minute=0, second=0):
from django.utils import timezone
return timezone.make_aware(datetime(year, month, day, hour, minute, second), timezone.get_current_timezone())
current_timezone = timezone.get_current_timezone()
return timezone.make_aware(
datetime(year, month, day, hour, minute, second),
current_timezone,
)
def test_time_entry_filter_supports_project_client_tags_and_custom_dates(db):
class TimeEntryFilterTests(TestCase):
def test_time_entry_filter_supports_project_client_tags_and_custom_dates(self):
user = User.objects.create_user(mobile="09124444444", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
client_a = Client.objects.create(workspace=workspace, name="Client A")
client_b = Client.objects.create(workspace=workspace, name="Client B")
project_a = Project.objects.create(workspace=workspace, client=client_a, name="Project A")
project_b = Project.objects.create(workspace=workspace, client=client_b, name="Project B")
tag_backend = Tag.objects.create(workspace=workspace, name="Backend", color="#0EA5E9")
project_a = Project.objects.create(
workspace=workspace,
client=client_a,
name="Project A",
)
project_b = Project.objects.create(
workspace=workspace,
client=client_b,
name="Project B",
)
tag_backend = Tag.objects.create(
workspace=workspace,
name="Backend",
color="#0EA5E9",
)
tag_ops = Tag.objects.create(workspace=workspace, name="Ops", color="#10B981")
entry_a = TimeEntry.objects.create(
@@ -59,10 +78,9 @@ def test_time_entry_filter_supports_project_client_tags_and_custom_dates(db):
queryset=queryset,
).qs
assert list(filtered) == [entry_a]
self.assertEqual(list(filtered), [entry_a])
def test_time_entry_filter_supports_status_values(db):
def test_time_entry_filter_supports_status_values(self):
user = User.objects.create_user(mobile="09125555555", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
@@ -85,5 +103,5 @@ def test_time_entry_filter_supports_status_values(db):
ended = TimeEntryFilter(data={"status": "ended"}, queryset=queryset).qs
running = TimeEntryFilter(data={"status": "running"}, queryset=queryset).qs
assert list(ended) == [ended_entry]
assert list(running) == [running_entry]
self.assertEqual(list(ended), [ended_entry])
self.assertEqual(list(running), [running_entry])

View File

@@ -1,22 +1,30 @@
from datetime import datetime
from django.test import TestCase
from django.utils import timezone
from apps.time_entries.api.serializers import TimeEntrySerializer
from apps.time_entries.models import TimeEntry
from apps.projects.models import Project
from apps.tags.models import Tag
from apps.time_entries.api.serializers import TimeEntrySerializer
from apps.time_entries.models import TimeEntry
from apps.users.models import User
from apps.workspaces.models import Workspace
def test_time_entry_serializer_keeps_seconds(db):
class TimeEntrySerializerTests(TestCase):
def test_time_entry_serializer_keeps_seconds(self):
user = User.objects.create_user(mobile="09123333333", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
current_timezone = timezone.get_current_timezone()
start_time = timezone.make_aware(datetime(2026, 4, 23, 10, 15, 42), current_timezone)
end_time = timezone.make_aware(datetime(2026, 4, 23, 11, 0, 5), current_timezone)
start_time = timezone.make_aware(
datetime(2026, 4, 23, 10, 15, 42),
current_timezone,
)
end_time = timezone.make_aware(
datetime(2026, 4, 23, 11, 0, 5),
current_timezone,
)
entry = TimeEntry.objects.create(
workspace=workspace,
@@ -27,11 +35,10 @@ def test_time_entry_serializer_keeps_seconds(db):
data = TimeEntrySerializer(entry).data
assert data["start_time"] == start_time.strftime("%Y-%m-%d %H:%M:%S")
assert data["end_time"] == end_time.strftime("%Y-%m-%d %H:%M:%S")
self.assertEqual(data["start_time"], start_time.strftime("%Y-%m-%d %H:%M:%S"))
self.assertEqual(data["end_time"], end_time.strftime("%Y-%m-%d %H:%M:%S"))
def test_time_entry_serializer_includes_deleted_project_and_tags(db):
def test_time_entry_serializer_includes_deleted_project_and_tags(self):
user = User.objects.create_user(mobile="09124444444", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
project = Project.objects.create(workspace=workspace, name="Legacy Project")
@@ -51,9 +58,9 @@ def test_time_entry_serializer_includes_deleted_project_and_tags(db):
data = TimeEntrySerializer(entry).data
assert data["project"] == str(project.id)
assert data["project_details"]["name"] == "Legacy Project"
assert data["project_details"]["is_deleted"] is True
assert data["tags"] == [str(tag.id)]
assert data["tag_details"][0]["name"] == "Legacy Tag"
assert data["tag_details"][0]["is_deleted"] is True
self.assertEqual(data["project"], str(project.id))
self.assertEqual(data["project_details"]["name"], "Legacy Project")
self.assertTrue(data["project_details"]["is_deleted"])
self.assertEqual(data["tags"], [str(tag.id)])
self.assertEqual(data["tag_details"][0]["name"], "Legacy Tag")
self.assertTrue(data["tag_details"][0]["is_deleted"])

View File

@@ -1,61 +1,62 @@
from datetime import timedelta
import pytest
from django.test import TestCase
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from apps.projects.models import Project
from apps.tags.models import Tag
from apps.time_entries.services.time_entries import create_time_entry, stop_time_entry, update_time_entry
from apps.time_entries.services.time_entries import (
create_time_entry,
stop_time_entry,
update_time_entry,
)
from apps.users.models import User
from apps.workspaces.models import Workspace
@pytest.fixture
def workspace_owner(db):
user = User.objects.create_user(mobile="09121111111", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
return user, workspace
def test_create_time_entry_allows_only_one_running_timer_per_workspace(workspace_owner):
user, workspace = workspace_owner
class TimeEntryServiceTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(mobile="09121111111", password="secret123")
cls.workspace = Workspace.objects.create(name="Core", owner=cls.user)
def test_create_time_entry_allows_only_one_running_timer_per_workspace(self):
create_time_entry(
user=user,
workspace_id=workspace.id,
user=self.user,
workspace_id=self.workspace.id,
start_time=timezone.now(),
)
with pytest.raises(ValidationError):
with self.assertRaises(ValidationError):
create_time_entry(
user=user,
workspace_id=workspace.id,
user=self.user,
workspace_id=self.workspace.id,
start_time=timezone.now() + timedelta(minutes=5),
)
def test_stop_time_entry_sets_end_time_and_duration(workspace_owner):
user, workspace = workspace_owner
def test_stop_time_entry_sets_end_time_and_duration(self):
entry = create_time_entry(
user=user,
workspace_id=workspace.id,
user=self.user,
workspace_id=self.workspace.id,
start_time=timezone.now() - timedelta(hours=1),
)
stopped_entry = stop_time_entry(entry, end_time=timezone.now())
assert stopped_entry.end_time is not None
assert stopped_entry.duration is not None
self.assertIsNotNone(stopped_entry.end_time)
self.assertIsNotNone(stopped_entry.duration)
def test_update_time_entry_preserves_deleted_project_and_tags(workspace_owner):
user, workspace = workspace_owner
project = Project.objects.create(workspace=workspace, name="Deleted project")
tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#0f172a")
def test_update_time_entry_preserves_deleted_project_and_tags(self):
project = Project.objects.create(workspace=self.workspace, name="Deleted project")
tag = Tag.objects.create(
workspace=self.workspace,
name="Deleted tag",
color="#0f172a",
)
entry = create_time_entry(
user=user,
workspace_id=workspace.id,
user=self.user,
workspace_id=self.workspace.id,
start_time=timezone.now() - timedelta(hours=1),
end_time=timezone.now(),
project=project,
@@ -73,6 +74,14 @@ def test_update_time_entry_preserves_deleted_project_and_tags(workspace_owner):
description="After delete",
)
assert updated_entry.description == "After delete"
assert updated_entry.project_id == project.id
assert list(Tag.all_objects.filter(time_entries=updated_entry).values_list("id", flat=True)) == [tag.id]
self.assertEqual(updated_entry.description, "After delete")
self.assertEqual(updated_entry.project_id, project.id)
self.assertEqual(
list(
Tag.all_objects.filter(time_entries=updated_entry).values_list(
"id",
flat=True,
)
),
[tag.id],
)

View File

@@ -1,7 +1,7 @@
from datetime import datetime
from django.utils import timezone
from rest_framework.test import APIClient
from rest_framework.test import APITestCase
from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry
@@ -10,10 +10,15 @@ from apps.workspaces.models import Workspace
def make_aware(year, month, day, hour=9, minute=0, second=0):
return timezone.make_aware(datetime(year, month, day, hour, minute, second), timezone.get_current_timezone())
current_timezone = timezone.get_current_timezone()
return timezone.make_aware(
datetime(year, month, day, hour, minute, second),
current_timezone,
)
def test_time_entry_list_returns_grouped_payload_for_ended_entries(db):
class TimeEntryViewTests(APITestCase):
def test_time_entry_list_returns_grouped_payload_for_ended_entries(self):
user = User.objects.create_user(mobile="09126666666", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
@@ -31,10 +36,8 @@ def test_time_entry_list_returns_grouped_payload_for_ended_entries(db):
start_time=make_aware(2026, 4, 24, 11, 0, 0),
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(
self.client.force_authenticate(user=user)
response = self.client.get(
"/api/time-entries/",
{
"workspace": str(workspace.id),
@@ -44,15 +47,17 @@ def test_time_entry_list_returns_grouped_payload_for_ended_entries(db):
},
)
assert response.status_code == 200
assert response.data["current_page_items_count"] == 1
assert response.data["has_more"] is False
assert len(response.data["groups"]) == 1
assert len(response.data["groups"][0]["days"]) == 1
assert response.data["groups"][0]["days"][0]["entries"][0]["id"] == str(first_entry.id)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["current_page_items_count"], 1)
self.assertFalse(response.data["has_more"])
self.assertEqual(len(response.data["groups"]), 1)
self.assertEqual(len(response.data["groups"][0]["days"]), 1)
self.assertEqual(
response.data["groups"][0]["days"][0]["entries"][0]["id"],
str(first_entry.id),
)
def test_time_entry_update_preserves_current_deleted_tags(db):
def test_time_entry_update_preserves_current_deleted_tags(self):
user = User.objects.create_user(mobile="09127777777", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#475569")
@@ -66,10 +71,8 @@ def test_time_entry_update_preserves_current_deleted_tags(db):
entry.tags.set([tag])
tag.delete()
client = APIClient()
client.force_authenticate(user=user)
response = client.patch(
self.client.force_authenticate(user=user)
response = self.client.patch(
f"/api/time-entries/{entry.id}/",
{
"description": "Still editable",
@@ -78,15 +81,18 @@ def test_time_entry_update_preserves_current_deleted_tags(db):
format="json",
)
assert response.status_code == 200
assert response.data["description"] == "Still editable"
assert response.data["tag_details"][0]["is_deleted"] is True
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["description"], "Still editable")
self.assertTrue(response.data["tag_details"][0]["is_deleted"])
def test_time_entry_update_rejects_new_deleted_tag_attachment(db):
def test_time_entry_update_rejects_new_deleted_tag_attachment(self):
user = User.objects.create_user(mobile="09128888888", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
deleted_tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#475569")
deleted_tag = Tag.objects.create(
workspace=workspace,
name="Deleted tag",
color="#475569",
)
deleted_tag.delete()
entry = TimeEntry.objects.create(
workspace=workspace,
@@ -96,25 +102,24 @@ def test_time_entry_update_rejects_new_deleted_tag_attachment(db):
end_time=make_aware(2026, 4, 24, 10, 30, 0),
)
client = APIClient()
client.force_authenticate(user=user)
response = client.patch(
self.client.force_authenticate(user=user)
response = self.client.patch(
f"/api/time-entries/{entry.id}/",
{
"tags": [str(deleted_tag.id)],
},
{"tags": [str(deleted_tag.id)]},
format="json",
)
assert response.status_code == 400
assert "unavailable" in response.data["error"].lower()
self.assertEqual(response.status_code, 400)
self.assertIn("unavailable", response.data["error"].lower())
def test_time_entry_update_can_remove_current_deleted_tag(db):
def test_time_entry_update_can_remove_current_deleted_tag(self):
user = User.objects.create_user(mobile="09129999999", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
deleted_tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#475569")
deleted_tag = Tag.objects.create(
workspace=workspace,
name="Deleted tag",
color="#475569",
)
entry = TimeEntry.objects.create(
workspace=workspace,
user=user,
@@ -125,16 +130,12 @@ def test_time_entry_update_can_remove_current_deleted_tag(db):
entry.tags.set([deleted_tag])
deleted_tag.delete()
client = APIClient()
client.force_authenticate(user=user)
response = client.patch(
self.client.force_authenticate(user=user)
response = self.client.patch(
f"/api/time-entries/{entry.id}/",
{
"tags": [],
},
{"tags": []},
format="json",
)
assert response.status_code == 200
assert response.data["tags"] == []
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["tags"], [])

View File

@@ -1,23 +1,11 @@
import logging
import random
import string
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from django_redis import get_redis_connection
from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers
from core.serializers.base import BaseModelSerializer
from apps.users.tasks import send_verification_sms
from apps.users.utils import record_login_attempt
User = get_user_model()
logger = logging.getLogger(__name__)
class UserProfilePictureSerializer(BaseModelSerializer):
class Meta:
@@ -51,10 +39,10 @@ class RegisterSerializer(serializers.Serializer):
re_password = data.get("re_password", "")
if not (mobile.isdigit() and len(mobile) == 11):
raise serializers.ValidationError({"mobile": "فرمت شماره موبایل نادرست است."})
raise serializers.ValidationError({"mobile": "فرمت شماره موبایل نادرست است."})
if password != re_password:
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
return data
@@ -65,11 +53,8 @@ class SendOTPSerializer(serializers.Serializer):
mode = serializers.ChoiceField(choices=["register", "login", "forget_password"])
def validate_mobile(self, value):
"""
Normalize and validate Iranian mobile numbers (example: 09XXXXXXXXX).
"""
if not value.isdigit() or len(value) != 11 or not value.startswith("09"):
raise serializers.ValidationError("شماره موبایل معتبر نیست.")
raise serializers.ValidationError("شماره موبایل معتبر نیست.")
return value
@@ -80,7 +65,7 @@ class LoginOtpSerializer(serializers.Serializer):
def validate_mobile(self, value):
if not (value.isdigit() and len(value) == 11):
raise serializers.ValidationError("فرمت شماره موبایل نادرست است.")
raise serializers.ValidationError("فرمت شماره موبایل نادرست است.")
return value
@@ -90,10 +75,30 @@ class LoginSerializer(serializers.Serializer):
def validate_mobile(self, value):
if not (value.isdigit() and len(value) == 11):
raise serializers.ValidationError("فرمت شماره موبایل نادرست است.")
raise serializers.ValidationError("فرمت شماره موبایل نادرست است.")
return value
class GoogleOAuthFlowSerializer(serializers.Serializer):
flow = serializers.CharField()
class GoogleOAuthCompleteSerializer(serializers.Serializer):
flow = serializers.CharField()
mobile = serializers.CharField(max_length=11)
def validate_mobile(self, value):
normalized = "".join(ch for ch in value if ch.isdigit())
if len(normalized) != 11 or not normalized.startswith("09"):
raise serializers.ValidationError("فرمت شماره موبایل نادرست است.")
return normalized
class GoogleOAuthClaimVerifySerializer(serializers.Serializer):
flow = serializers.CharField()
code = serializers.CharField(max_length=6)
class ResetPasswordSerializer(serializers.Serializer):
mobile = serializers.CharField(max_length=11)
code = serializers.CharField(max_length=6)
@@ -102,7 +107,7 @@ class ResetPasswordSerializer(serializers.Serializer):
def validate(self, data):
if data.get("password") != data.get("re_password"):
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
return data
@@ -113,7 +118,7 @@ class ChangePasswordSerializer(serializers.Serializer):
def validate(self, data):
if data.get("new_password") != data.get("re_password"):
raise serializers.ValidationError({"new_password": "رمز عبور جدید و تکرار آن مطابقت ندارند."})
raise serializers.ValidationError({"new_password": "رمز عبور جدید و تکرار آن مطابقت ندارند."})
return data
@@ -138,9 +143,16 @@ class UserProfileSerializer(BaseModelSerializer):
class Meta:
model = User
fields = BaseModelSerializer.Meta.fields + (
"mobile", "email", "first_name", "last_name",
"description", "profile_picture", "birth_date",
"is_verified", "full_name", "age"
"mobile",
"email",
"first_name",
"last_name",
"description",
"profile_picture",
"birth_date",
"is_verified",
"full_name",
"age",
)
read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified")
@@ -149,9 +161,9 @@ class UserSearchSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = (
'id',
'first_name',
'last_name',
'mobile',
'profile_picture',
"id",
"first_name",
"last_name",
"mobile",
"profile_picture",
)

140
apps/users/api/throttles.py Normal file
View File

@@ -0,0 +1,140 @@
from __future__ import annotations
import re
from django.core.exceptions import ImproperlyConfigured
from rest_framework.settings import api_settings
from rest_framework.throttling import SimpleRateThrottle
class ScopedMobileThrottle(SimpleRateThrottle):
scope = ""
def get_rate(self):
if not self.scope:
raise ImproperlyConfigured(
f"{self.__class__.__name__} must define a scope or rate."
)
try:
return api_settings.DEFAULT_THROTTLE_RATES[self.scope]
except KeyError as exc:
raise ImproperlyConfigured(
f'No default throttle rate set for scope "{self.scope}".'
) from exc
def parse_rate(self, rate):
if rate is None:
return (None, None)
num_requests, period = rate.split("/")
match = re.fullmatch(r"(?:(\d+)\s*)?([smhd]|sec|second|min|minute|hour|day)s?", period.strip(), re.IGNORECASE)
if not match:
return super().parse_rate(rate)
multiplier = int(match.group(1) or "1")
unit = match.group(2).lower()
unit_seconds = {
"s": 1,
"sec": 1,
"second": 1,
"m": 60,
"min": 60,
"minute": 60,
"h": 3600,
"hour": 3600,
"d": 86400,
"day": 86400,
}[unit]
return int(num_requests), multiplier * unit_seconds
def get_mobile_identifier(self, request) -> str | None:
mobile = None
try:
mobile = request.data.get("mobile")
except Exception:
mobile = None
if not isinstance(mobile, str):
return None
normalized = "".join(ch for ch in mobile if ch.isdigit())
return normalized or None
def get_cache_key(self, request, view):
ident = self.get_ident(request)
mobile = self.get_mobile_identifier(request)
if mobile:
return self.cache_format % {
"scope": self.scope,
"ident": f"{ident}:{mobile}",
}
return self.cache_format % {
"scope": self.scope,
"ident": ident,
}
def allow_request(self, request, view):
allowed = super().allow_request(request, view)
if not allowed:
request._throttle_scope = self.scope
request._retry_after_seconds = self.wait()
return allowed
class ScopedFlowMobileThrottle(ScopedMobileThrottle):
def get_mobile_identifier(self, request) -> str | None:
mobile = super().get_mobile_identifier(request)
if mobile:
return mobile
try:
flow = request.data.get("flow")
except Exception:
flow = None
if not isinstance(flow, str) or not flow:
return None
try:
from apps.users.services.google_oauth import get_google_flow
flow_payload = get_google_flow(flow)
except Exception:
return None
mobile = flow_payload.get("mobile")
if not isinstance(mobile, str):
return None
normalized = "".join(ch for ch in mobile if ch.isdigit())
return normalized or None
class OTPSendBurstThrottle(ScopedMobileThrottle):
scope = "otp_send_burst"
class OTPSendSustainedThrottle(ScopedMobileThrottle):
scope = "otp_send_sustained"
class PasswordLoginThrottle(ScopedMobileThrottle):
scope = "login_password"
class OTPLoginThrottle(ScopedMobileThrottle):
scope = "login_otp"
class GoogleClaimSendBurstThrottle(ScopedFlowMobileThrottle):
scope = "otp_send_burst"
class GoogleClaimSendSustainedThrottle(ScopedFlowMobileThrottle):
scope = "otp_send_sustained"
class GoogleClaimVerifyThrottle(ScopedFlowMobileThrottle):
scope = "login_otp"

View File

@@ -9,9 +9,16 @@ app_name = "users"
urlpatterns = [
path("register/", views.RegisterWithOTPView.as_view(), name="register_verify"),
path("register/password/", views.RegisterWithPasswordView.as_view(), name="register_password"),
path("otp/send/", views.SendOTPView.as_view(), name="send_otp"),
path("otp/login/", views.LoginOTPView.as_view(), name="login_otp"),
path("login/", views.LoginView.as_view(), name="login"),
path("oauth/google/start/", views.GoogleOAuthStartView.as_view(), name="google_oauth_start"),
path("oauth/google/callback/", views.GoogleOAuthCallbackView.as_view(), name="google_oauth_callback"),
path("oauth/google/flow/", views.GoogleOAuthFlowView.as_view(), name="google_oauth_flow"),
path("oauth/google/complete/", views.GoogleOAuthCompleteView.as_view(), name="google_oauth_complete"),
path("oauth/google/claim/send-otp/", views.GoogleOAuthClaimSendOtpView.as_view(), name="google_oauth_claim_send_otp"),
path("oauth/google/claim/verify/", views.GoogleOAuthClaimVerifyView.as_view(), name="google_oauth_claim_verify"),
path("logout/", views.LogoutView.as_view(), name="logout"),
path("password/set/", views.SetPasswordView.as_view(), name="set_password"),
path("password/reset/", views.ResetPasswordView.as_view(), name="reset_password"),

View File

@@ -1,3 +1,4 @@
from django.http import HttpResponseRedirect
from django.contrib.auth import get_user_model
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, inline_serializer
@@ -19,6 +20,9 @@ from apps.users.api.serializers import (
ChangePasswordSerializer,
LoginOtpSerializer,
LoginSerializer,
GoogleOAuthClaimVerifySerializer,
GoogleOAuthCompleteSerializer,
GoogleOAuthFlowSerializer,
RegisterSerializer,
ResetPasswordSerializer,
SendOTPSerializer,
@@ -30,6 +34,15 @@ from apps.users.api.serializers import (
UserProfileSerializer,
UserSearchSerializer,
)
from apps.users.api.throttles import (
OTPLoginThrottle,
OTPSendBurstThrottle,
OTPSendSustainedThrottle,
PasswordLoginThrottle,
GoogleClaimSendBurstThrottle,
GoogleClaimSendSustainedThrottle,
GoogleClaimVerifyThrottle,
)
from apps.users.services.auth import (
register_user_with_password,
register_user_with_otp,
@@ -40,6 +53,20 @@ from apps.users.services.auth import (
change_password,
logout_user
)
from apps.users.services.google_oauth import (
build_authenticated_flow_payload,
build_google_authorization_url,
build_google_callback_redirect_url,
build_pending_google_flow_payload,
complete_google_signup,
consume_google_state,
create_google_flow,
exchange_code_for_google_profile,
find_social_account_for_profile,
get_google_flow,
send_google_claim_otp,
verify_google_claim,
)
User = get_user_model()
@@ -91,6 +118,7 @@ class SendOTPView(APIView):
+ password reset
"""
permission_classes = (AllowAny,)
throttle_classes = [OTPSendBurstThrottle, OTPSendSustainedThrottle]
@extend_schema(request=SendOTPSerializer, responses=None)
def post(self, request):
@@ -107,6 +135,7 @@ class SendOTPView(APIView):
class LoginView(APIView):
permission_classes = (AllowAny,)
throttle_classes = [PasswordLoginThrottle]
@extend_schema(request=LoginSerializer, responses=TokenPairSerializer)
def post(self, request):
@@ -123,6 +152,7 @@ class LoginView(APIView):
class LoginOTPView(APIView):
permission_classes = (AllowAny,)
throttle_classes = [OTPLoginThrottle]
@extend_schema(request=LoginOtpSerializer, responses=TokenPairSerializer)
def post(self, request):
@@ -137,6 +167,88 @@ class LoginOTPView(APIView):
return Response(tokens, status=status.HTTP_200_OK)
class GoogleOAuthStartView(APIView):
permission_classes = (AllowAny,)
@extend_schema(responses=None)
def get(self, request):
return HttpResponseRedirect(build_google_authorization_url())
class GoogleOAuthCallbackView(APIView):
permission_classes = (AllowAny,)
@extend_schema(responses=None)
def get(self, request):
if request.query_params.get("error"):
raise serializers.ValidationError(
{"detail": request.query_params.get("error_description") or "Google sign-in was cancelled."}
)
consume_google_state(request.query_params.get("state"))
profile = exchange_code_for_google_profile(request.query_params.get("code"))
social_account = find_social_account_for_profile(profile)
if social_account:
flow_payload = build_authenticated_flow_payload(social_account.user)
else:
flow_payload = build_pending_google_flow_payload(profile)
flow = create_google_flow(flow_payload)
return HttpResponseRedirect(build_google_callback_redirect_url(flow))
class GoogleOAuthFlowView(APIView):
permission_classes = (AllowAny,)
@extend_schema(responses=None)
def get(self, request):
serializer = GoogleOAuthFlowSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
return Response(get_google_flow(serializer.validated_data["flow"]), status=status.HTTP_200_OK)
class GoogleOAuthCompleteView(APIView):
permission_classes = (AllowAny,)
@extend_schema(request=GoogleOAuthCompleteSerializer, responses=None)
def post(self, request):
serializer = GoogleOAuthCompleteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = complete_google_signup(
flow=serializer.validated_data["flow"],
mobile=serializer.validated_data["mobile"],
)
return Response(payload, status=status.HTTP_200_OK)
class GoogleOAuthClaimSendOtpView(APIView):
permission_classes = (AllowAny,)
throttle_classes = [GoogleClaimSendBurstThrottle, GoogleClaimSendSustainedThrottle]
@extend_schema(request=GoogleOAuthFlowSerializer, responses=None)
def post(self, request):
serializer = GoogleOAuthFlowSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = send_google_claim_otp(serializer.validated_data["flow"])
return Response(payload, status=status.HTTP_200_OK)
class GoogleOAuthClaimVerifyView(APIView):
permission_classes = (AllowAny,)
throttle_classes = [GoogleClaimVerifyThrottle]
@extend_schema(request=GoogleOAuthClaimVerifySerializer, responses=None)
def post(self, request):
serializer = GoogleOAuthClaimVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = verify_google_claim(
flow=serializer.validated_data["flow"],
code=serializer.validated_data["code"],
)
return Response(payload, status=status.HTTP_200_OK)
class ResetPasswordView(APIView):
permission_classes = (AllowAny,)
serializer_class = ResetPasswordSerializer

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.12 on 2026-04-30 20:35
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='UserSocialAccount',
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)),
('provider', models.CharField(choices=[('google', 'google')], max_length=32)),
('provider_user_id', models.CharField(max_length=255)),
('email', models.EmailField(blank=True, default='', max_length=254)),
('email_verified', models.BooleanField(default=False)),
('avatar_url', models.URLField(blank=True, default='')),
('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)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='social_accounts', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'user_social_account',
'verbose_name_plural': 'user_social_accounts',
'db_table': 'user_social_account',
'ordering': ('-updated_at', '-created_at'),
'indexes': [models.Index(fields=['provider', 'provider_user_id'], name='user_social_provider_uid_idx'), models.Index(fields=['provider', 'email'], name='user_social_provider_email_idx')],
'constraints': [models.UniqueConstraint(fields=('provider', 'provider_user_id'), name='user_social_account_provider_uid_uniq')],
},
),
]

View File

@@ -69,3 +69,34 @@ class LoginAttempt(BaseModel):
def __str__(self):
return f"LoginAttempt for User: {self.user} ({'' if self.status else ''})"
class UserSocialAccount(BaseModel):
class ProviderType(models.TextChoices):
GOOGLE = "google", "google"
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="social_accounts")
provider = models.CharField(max_length=32, choices=ProviderType.choices)
provider_user_id = models.CharField(max_length=255)
email = models.EmailField(blank=True, default="")
email_verified = models.BooleanField(default=False)
avatar_url = models.URLField(blank=True, default="")
class Meta:
verbose_name = "user_social_account"
verbose_name_plural = "user_social_accounts"
db_table = "user_social_account"
ordering = ("-updated_at", "-created_at")
constraints = (
models.UniqueConstraint(
fields=("provider", "provider_user_id"),
name="user_social_account_provider_uid_uniq",
),
)
indexes = (
models.Index(fields=["provider", "provider_user_id"], name="user_social_provider_uid_idx"),
models.Index(fields=["provider", "email"], name="user_social_provider_email_idx"),
)
def __str__(self):
return f"{self.provider}:{self.provider_user_id}"

View File

@@ -0,0 +1,333 @@
from __future__ import annotations
import secrets
from dataclasses import asdict, dataclass, is_dataclass
from typing import Any
from urllib.parse import urlencode
import requests
from django.conf import settings
from django.core.cache import cache
from rest_framework.exceptions import ValidationError
from apps.users.models import User, UserSocialAccount
from apps.users.services.auth import generate_and_send_otp, get_tokens_for_user
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"
GOOGLE_STATE_TTL_SECONDS = 300
GOOGLE_FLOW_TTL_SECONDS = 900
GOOGLE_STATE_CACHE_PREFIX = "google_oauth_state"
GOOGLE_FLOW_CACHE_PREFIX = "google_oauth_flow"
@dataclass
class GoogleProfile:
provider_user_id: str
email: str
email_verified: bool
first_name: str
last_name: str
avatar_url: str
def _google_required_setting(name: str) -> str:
value = getattr(settings, name, "")
if not value:
raise ValidationError({"detail": f"{name} is not configured."})
return value
def _cache_key(prefix: str, token: str) -> str:
return f"{prefix}:{token}"
def _create_token() -> str:
return secrets.token_urlsafe(32)
def create_google_state() -> str:
state = _create_token()
cache.set(_cache_key(GOOGLE_STATE_CACHE_PREFIX, state), {"valid": True}, GOOGLE_STATE_TTL_SECONDS)
return state
def consume_google_state(state: str) -> dict[str, Any]:
if not state:
raise ValidationError({"detail": "Missing OAuth state."})
key = _cache_key(GOOGLE_STATE_CACHE_PREFIX, state)
payload = cache.get(key)
cache.delete(key)
if not payload:
raise ValidationError({"detail": "Invalid or expired OAuth state."})
return payload
def create_google_flow(payload: dict[str, Any]) -> str:
flow = _create_token()
cache.set(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow), payload, GOOGLE_FLOW_TTL_SECONDS)
return flow
def get_google_flow(flow: str) -> dict[str, Any]:
payload = cache.get(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow))
if not payload:
raise ValidationError({"detail": "Google sign-in flow is invalid or expired."})
return payload
def delete_google_flow(flow: str) -> None:
cache.delete(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow))
def update_google_flow(flow: str, payload: dict[str, Any]) -> None:
cache.set(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow), payload, GOOGLE_FLOW_TTL_SECONDS)
def build_google_authorization_url() -> str:
state = create_google_state()
params = {
"client_id": _google_required_setting("GOOGLE_OAUTH_CLIENT_ID"),
"redirect_uri": _google_required_setting("GOOGLE_OAUTH_REDIRECT_URI"),
"response_type": "code",
"scope": "openid email profile",
"state": state,
"access_type": "online",
"prompt": "select_account",
}
return f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
def exchange_code_for_google_profile(code: str) -> GoogleProfile:
if not code:
raise ValidationError({"detail": "Missing Google authorization code."})
try:
token_response = requests.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": _google_required_setting("GOOGLE_OAUTH_CLIENT_ID"),
"client_secret": _google_required_setting("GOOGLE_OAUTH_CLIENT_SECRET"),
"redirect_uri": _google_required_setting("GOOGLE_OAUTH_REDIRECT_URI"),
"grant_type": "authorization_code",
},
timeout=10,
)
token_response.raise_for_status()
token_payload = token_response.json()
except requests.RequestException as exc:
raise ValidationError({"detail": "Google token exchange failed."}) from exc
access_token = token_payload.get("access_token")
if not access_token:
raise ValidationError({"detail": "Google did not return an access token."})
try:
userinfo_response = requests.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
userinfo_response.raise_for_status()
userinfo = userinfo_response.json()
except requests.RequestException as exc:
raise ValidationError({"detail": "Google user profile lookup failed."}) from exc
provider_user_id = userinfo.get("sub", "")
email = userinfo.get("email", "")
email_verified = bool(userinfo.get("email_verified"))
if not provider_user_id or not email or not email_verified:
raise ValidationError({"detail": "Google account must have a verified email address."})
return GoogleProfile(
provider_user_id=provider_user_id,
email=email,
email_verified=email_verified,
first_name=userinfo.get("given_name", "") or "",
last_name=userinfo.get("family_name", "") or "",
avatar_url=userinfo.get("picture", "") or "",
)
def get_frontend_google_callback_url() -> str:
return _google_required_setting("GOOGLE_OAUTH_FRONTEND_CALLBACK_URL")
def build_google_callback_redirect_url(flow: str) -> str:
return f"{get_frontend_google_callback_url()}?flow={flow}"
def find_social_account_for_profile(profile: GoogleProfile) -> UserSocialAccount | None:
return (
UserSocialAccount.objects.select_related("user")
.filter(
provider=UserSocialAccount.ProviderType.GOOGLE,
provider_user_id=profile.provider_user_id,
)
.first()
)
def build_authenticated_flow_payload(user: User) -> dict[str, Any]:
tokens = get_tokens_for_user(user)
return {
"status": "authenticated",
"access": tokens["access"],
"refresh": tokens["refresh"],
}
def build_pending_google_flow_payload(profile: GoogleProfile) -> dict[str, Any]:
profile_payload = asdict(profile) if is_dataclass(profile) else {
"provider_user_id": profile.provider_user_id,
"email": profile.email,
"email_verified": profile.email_verified,
"first_name": profile.first_name,
"last_name": profile.last_name,
"avatar_url": profile.avatar_url,
}
return {
"status": "collect_mobile",
"google_profile": profile_payload,
"email": profile.email,
"first_name": profile.first_name,
"last_name": profile.last_name,
"avatar_url": profile.avatar_url,
}
def _profile_from_flow(flow_payload: dict[str, Any]) -> GoogleProfile:
google_profile = flow_payload.get("google_profile")
if not isinstance(google_profile, dict):
raise ValidationError({"detail": "Google profile is missing from the flow."})
return GoogleProfile(**google_profile)
def _normalize_mobile(mobile: str) -> str:
normalized = "".join(ch for ch in mobile if ch.isdigit())
if len(normalized) != 11 or not normalized.startswith("09"):
raise ValidationError({"mobile": "Invalid mobile number."})
return normalized
def complete_google_signup(flow: str, mobile: str) -> dict[str, Any]:
flow_payload = get_google_flow(flow)
if flow_payload.get("status") != "collect_mobile":
raise ValidationError({"detail": "Google sign-in flow is not ready for mobile completion."})
normalized_mobile = _normalize_mobile(mobile)
profile = _profile_from_flow(flow_payload)
existing_user = User.objects.filter(mobile=normalized_mobile).first()
if existing_user:
generate_and_send_otp(normalized_mobile, "login")
claim_payload = {
"status": "claim_required",
"google_profile": asdict(profile),
"mobile": normalized_mobile,
"user_id": str(existing_user.id),
}
update_google_flow(flow, claim_payload)
return {
"status": "claim_required",
"mobile": normalized_mobile,
"detail": "Existing account found. Verify ownership to attach Google.",
}
user = User.objects.create_user(
mobile=normalized_mobile,
password=None,
first_name=profile.first_name,
last_name=profile.last_name,
email=profile.email,
is_verified=False,
is_active=True,
)
user.set_unusable_password()
user.save(update_fields=["password"])
UserSocialAccount.objects.create(
user=user,
provider=UserSocialAccount.ProviderType.GOOGLE,
provider_user_id=profile.provider_user_id,
email=profile.email,
email_verified=profile.email_verified,
avatar_url=profile.avatar_url,
is_active=True,
)
authenticated_payload = build_authenticated_flow_payload(user)
update_google_flow(flow, authenticated_payload)
return authenticated_payload
def send_google_claim_otp(flow: str) -> dict[str, Any]:
flow_payload = get_google_flow(flow)
if flow_payload.get("status") != "claim_required":
raise ValidationError({"detail": "Google sign-in flow is not waiting for claim verification."})
mobile = flow_payload.get("mobile")
if not isinstance(mobile, str) or not mobile:
raise ValidationError({"detail": "Claim mobile number is missing."})
generate_and_send_otp(mobile, "login")
return {"detail": "Verification code sent successfully."}
def verify_google_claim(flow: str, code: str) -> dict[str, Any]:
from django_redis import get_redis_connection
flow_payload = get_google_flow(flow)
if flow_payload.get("status") != "claim_required":
raise ValidationError({"detail": "Google sign-in flow is not waiting for claim verification."})
mobile = flow_payload.get("mobile")
if not isinstance(mobile, str) or not mobile:
raise ValidationError({"detail": "Claim mobile number is missing."})
profile = _profile_from_flow(flow_payload)
user_id = flow_payload.get("user_id")
user = User.objects.filter(id=user_id, mobile=mobile).first()
if not user:
raise ValidationError({"detail": "Target account could not be found."})
redis_conn = get_redis_connection("default")
stored_code = redis_conn.get(f"verification_code:{mobile}")
if not stored_code or stored_code.decode("utf-8") != code:
raise ValidationError({"code": "Invalid or expired verification code."})
redis_conn.delete(f"verification_code:{mobile}")
existing_link = find_social_account_for_profile(profile)
if existing_link and existing_link.user_id != user.id:
raise ValidationError({"detail": "This Google account is already attached to another user."})
if existing_link:
existing_link.email = profile.email
existing_link.email_verified = profile.email_verified
existing_link.avatar_url = profile.avatar_url
existing_link.is_active = True
existing_link.save(
update_fields=["email", "email_verified", "avatar_url", "is_active", "updated_at"]
)
else:
UserSocialAccount.objects.create(
user=user,
provider=UserSocialAccount.ProviderType.GOOGLE,
provider_user_id=profile.provider_user_id,
email=profile.email,
email_verified=profile.email_verified,
avatar_url=profile.avatar_url,
is_active=True,
)
authenticated_payload = build_authenticated_flow_payload(user)
update_google_flow(flow, authenticated_payload)
return authenticated_payload

View File

@@ -9,35 +9,46 @@ logger = logging.getLogger(__name__)
def _send_sms(receptor, pattern_code, variables: list = None):
"""
Send OTP SMS using SMS.ir pattern-based API
Send OTP SMS using SMS.ir verify API
"""
SMS_ENDPOINT = "https://api.sms.ir/v1/send/verify"
variables = variables or []
headers = {"Content-Type": "application/json", "Accept": "text/plain", "x-api-key": settings.SMS_APIKEY}
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"x-api-key": settings.SMS_APIKEY,
}
payload = {
"mobile": receptor,
"templateId": str(pattern_code),
"templateId": int(pattern_code),
"parameters": variables,
}
logger.info(f"Sending SMS to {receptor} with payload: {payload}")
logger.info("Sending SMS to %s with payload: %s", receptor, payload)
try:
response = requests.post(SMS_ENDPOINT, data=payload, headers=headers, timeout=10)
response = requests.post(
SMS_ENDPOINT,
json=payload,
headers=headers,
timeout=10,
)
logger.info(f"Response status: {response.status_code}")
logger.info(f"Response text: {response.text}")
logger.info("Response status: %s", response.status_code)
logger.info("Response text: %s", response.text)
if response.status_code == 200:
result = response.json()
if str(result.get("status", "")) == "1":
logger.info(f"SMS sent successfully to {receptor}")
logger.info("SMS sent successfully to %s", receptor)
else:
logger.error(f"SMS.ir API error: {result}")
logger.error("SMS.ir API error: %s", result)
else:
logger.error(f"HTTP error sending SMS: {response.status_code} - {response.text}")
logger.error("HTTP error sending SMS: %s - %s", response.status_code, response.text)
return response

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,592 @@
from unittest.mock import patch
from django.conf import settings
from django.core.cache import cache
from django.test import override_settings
from rest_framework.test import APIRequestFactory
from rest_framework import status
from rest_framework.test import APITestCase
from apps.users.api.views import RegisterWithPasswordView
from apps.users.models import User, UserSocialAccount
class UserApiViewTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
mobile="09123330001",
password="secret123",
first_name="Ali",
last_name="Test",
)
cls.other_user = User.objects.create_user(
mobile="09123330002",
password="secret123",
first_name="Sara",
last_name="Search",
)
@patch("apps.users.api.views.register_user_with_password")
def test_register_with_password_view_returns_tokens(self, register_user_with_password):
register_user_with_password.return_value = {
"access": "access-token",
"refresh": "refresh-token",
}
request = APIRequestFactory().post(
"/api/users/register/password/",
{"mobile": "09123330009", "password": "secret123"},
format="json",
)
response = RegisterWithPasswordView.as_view()(request)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["access"], "access-token")
register_user_with_password.assert_called_once_with("09123330009", "secret123")
def test_register_with_password_requires_mobile_and_password(self):
request = APIRequestFactory().post("/api/users/register/password/", {}, format="json")
response = RegisterWithPasswordView.as_view()(request)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@patch("apps.users.api.views.generate_and_send_otp")
def test_send_otp_view_validates_and_dispatches(self, generate_and_send_otp):
response = self.client.post(
"/api/users/otp/send/",
{"mobile": "09123330009", "mode": "login"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
generate_and_send_otp.assert_called_once_with(
mobile="09123330009",
mode="login",
)
@patch("apps.users.api.views.login_with_password")
def test_login_view_returns_tokens(self, login_with_password):
login_with_password.return_value = {"access": "a", "refresh": "r"}
response = self.client.post(
"/api/users/login/",
{"mobile": "09123330001", "password": "secret123"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["refresh"], "r")
login_with_password.assert_called_once()
@patch("apps.users.api.views.login_with_otp")
def test_login_otp_view_returns_tokens(self, login_with_otp):
login_with_otp.return_value = {"access": "a", "refresh": "r"}
response = self.client.post(
"/api/users/otp/login/",
{"mobile": "09123330001", "code": "123456"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["access"], "a")
@patch("apps.users.api.views.reset_password_with_otp")
def test_reset_password_view_calls_service(self, reset_password_with_otp):
response = self.client.post(
"/api/users/password/reset/",
{
"mobile": "09123330001",
"code": "123456",
"password": "new-secret123",
"re_password": "new-secret123",
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
reset_password_with_otp.assert_called_once_with(
mobile="09123330001",
code="123456",
password="new-secret123",
)
@patch("apps.users.api.views.change_password")
def test_change_password_view_requires_auth_and_calls_service(self, change_password):
self.client.force_authenticate(user=self.user)
response = self.client.patch(
"/api/users/password/change/",
{
"old_password": "secret123",
"new_password": "new-secret123",
"re_password": "new-secret123",
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
change_password.assert_called_once_with(
user=self.user,
old_password="secret123",
new_password="new-secret123",
)
@patch("apps.users.api.views.logout_user")
def test_logout_view_calls_service(self, logout_user):
self.client.force_authenticate(user=self.user)
response = self.client.post(
"/api/users/logout/",
{"refresh": "refresh-token"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_205_RESET_CONTENT)
logout_user.assert_called_once_with("refresh-token")
def test_user_list_and_profile_views_require_authentication(self):
list_response = self.client.get("/api/users/list/")
me_response = self.client.get("/api/users/me/")
self.assertEqual(list_response.status_code, status.HTTP_401_UNAUTHORIZED)
self.assertEqual(me_response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_user_list_returns_users_for_authenticated_request(self):
self.client.force_authenticate(user=self.user)
response = self.client.get("/api/users/list/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
items = (
response.data
if isinstance(response.data, list)
else response.data.get("results")
or response.data.get("items")
or []
)
mobiles = {item["mobile"] for item in items}
self.assertIn(self.user.mobile, mobiles)
self.assertIn(self.other_user.mobile, mobiles)
def test_user_me_retrieve_and_patch_work(self):
self.client.force_authenticate(user=self.user)
retrieve_response = self.client.get("/api/users/me/")
self.assertEqual(retrieve_response.status_code, status.HTTP_200_OK)
self.assertEqual(retrieve_response.data["mobile"], self.user.mobile)
patch_response = self.client.patch(
"/api/users/me/",
{"first_name": "Updated", "description": "Bio"},
format="json",
)
self.assertEqual(patch_response.status_code, status.HTTP_200_OK)
self.assertEqual(patch_response.data["first_name"], "Updated")
self.user.refresh_from_db()
self.assertEqual(self.user.description, "Bio")
def test_user_search_handles_missing_mobile_not_found_and_success(self):
self.client.force_authenticate(user=self.user)
missing = self.client.get("/api/users/search/")
self.assertEqual(missing.status_code, status.HTTP_400_BAD_REQUEST)
not_found = self.client.get("/api/users/search/?mobile=09129999999")
self.assertEqual(not_found.status_code, status.HTTP_404_NOT_FOUND)
success = self.client.get(f"/api/users/search/?mobile={self.other_user.mobile}")
self.assertEqual(success.status_code, status.HTTP_200_OK)
self.assertEqual(success.data["mobile"], self.other_user.mobile)
class UserThrottleTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
mobile="09124440001",
password="secret123",
)
def setUp(self):
cache.clear()
def tearDown(self):
cache.clear()
@override_settings(
REST_FRAMEWORK={
**settings.REST_FRAMEWORK,
"DEFAULT_THROTTLE_RATES": {
**settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"],
"login_password": "2/min",
},
}
)
@patch("apps.users.api.views.login_with_password")
def test_password_login_returns_structured_429_with_retry_after(self, login_with_password):
login_with_password.return_value = {"access": "a", "refresh": "r"}
first = self.client.post(
"/api/users/login/",
{"mobile": "09124440001", "password": "secret123"},
format="json",
REMOTE_ADDR="10.0.0.1",
)
second = self.client.post(
"/api/users/login/",
{"mobile": "09124440001", "password": "secret123"},
format="json",
REMOTE_ADDR="10.0.0.1",
)
throttled = self.client.post(
"/api/users/login/",
{"mobile": "09124440001", "password": "secret123"},
format="json",
REMOTE_ADDR="10.0.0.1",
)
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertEqual(throttled.status_code, 429)
self.assertEqual(throttled.data["code"], "throttled")
self.assertEqual(throttled.data["scope"], "login_password")
self.assertIsInstance(throttled.data["retry_after_seconds"], int)
self.assertTrue(throttled.data["throttled_until"])
self.assertIn("Retry-After", throttled.headers)
@override_settings(
REST_FRAMEWORK={
**settings.REST_FRAMEWORK,
"DEFAULT_THROTTLE_RATES": {
**settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"],
"otp_send_burst": "1/min",
"otp_send_sustained": "10/day",
},
}
)
@patch("apps.users.api.views.generate_and_send_otp")
def test_otp_send_throttle_is_keyed_by_mobile_and_ip(self, generate_and_send_otp):
first_mobile_first = self.client.post(
"/api/users/otp/send/",
{"mobile": "09124440011", "mode": "login"},
format="json",
REMOTE_ADDR="10.0.0.2",
)
second_mobile_first = self.client.post(
"/api/users/otp/send/",
{"mobile": "09124440012", "mode": "login"},
format="json",
REMOTE_ADDR="10.0.0.2",
)
first_mobile_second = self.client.post(
"/api/users/otp/send/",
{"mobile": "09124440011", "mode": "login"},
format="json",
REMOTE_ADDR="10.0.0.2",
)
self.assertEqual(first_mobile_first.status_code, 200)
self.assertEqual(second_mobile_first.status_code, 200)
self.assertEqual(first_mobile_second.status_code, 429)
self.assertEqual(first_mobile_second.data["scope"], "otp_send_burst")
@override_settings(
REST_FRAMEWORK={
**settings.REST_FRAMEWORK,
"DEFAULT_THROTTLE_RATES": {
**settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"],
"login_otp": "1/min",
},
}
)
@patch("apps.users.api.views.login_with_otp")
def test_otp_login_throttle_blocks_after_limit(self, login_with_otp):
login_with_otp.return_value = {"access": "a", "refresh": "r"}
allowed = self.client.post(
"/api/users/otp/login/",
{"mobile": "09124440021", "code": "123456"},
format="json",
REMOTE_ADDR="10.0.0.3",
)
throttled = self.client.post(
"/api/users/otp/login/",
{"mobile": "09124440021", "code": "123456"},
format="json",
REMOTE_ADDR="10.0.0.3",
)
self.assertEqual(allowed.status_code, 200)
self.assertEqual(throttled.status_code, 429)
self.assertEqual(throttled.data["scope"], "login_otp")
@patch.dict("rest_framework.throttling.AnonRateThrottle.THROTTLE_RATES", {"anon": "1/min"}, clear=False)
@patch("apps.users.api.views.register_user_with_otp")
def test_global_anon_throttle_applies_to_unrestricted_anonymous_endpoint(
self,
register_user_with_otp,
):
register_user_with_otp.return_value = {"access": "a", "refresh": "r"}
first = self.client.post(
"/api/users/register/",
{
"mobile": "09124440031",
"code": "12345",
"password": "secret123",
"re_password": "secret123",
},
format="json",
REMOTE_ADDR="10.0.0.4",
)
throttled = self.client.post(
"/api/users/register/",
{
"mobile": "09124440032",
"code": "12345",
"password": "secret123",
"re_password": "secret123",
},
format="json",
REMOTE_ADDR="10.0.0.4",
)
self.assertEqual(first.status_code, 201)
self.assertEqual(throttled.status_code, 429)
self.assertEqual(throttled.data["code"], "throttled")
@override_settings(
REST_FRAMEWORK={
**settings.REST_FRAMEWORK,
"DEFAULT_THROTTLE_RATES": {
**settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"],
"login_password": "1/min",
},
}
)
@patch("apps.users.api.views.login_with_password")
def test_throttle_falls_back_to_ip_when_mobile_is_missing(self, login_with_password):
login_with_password.return_value = {"access": "a", "refresh": "r"}
first = self.client.post(
"/api/users/login/",
{"password": "secret123"},
format="json",
REMOTE_ADDR="10.0.0.5",
)
second = self.client.post(
"/api/users/login/",
{"password": "secret123"},
format="json",
REMOTE_ADDR="10.0.0.5",
)
self.assertEqual(first.status_code, 400)
self.assertEqual(second.status_code, 429)
@override_settings(
GOOGLE_OAUTH_CLIENT_ID="google-client-id",
GOOGLE_OAUTH_CLIENT_SECRET="google-client-secret",
GOOGLE_OAUTH_REDIRECT_URI="http://testserver/api/users/oauth/google/callback/",
GOOGLE_OAUTH_FRONTEND_CALLBACK_URL="http://localhost:5173/auth/google/callback",
)
class GoogleOAuthApiTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
mobile="09125550001",
password="secret123",
first_name="Google",
last_name="Linked",
)
def setUp(self):
cache.clear()
def tearDown(self):
cache.clear()
def test_google_start_redirects_to_google_authorization_url(self):
response = self.client.get("/api/users/oauth/google/start/")
self.assertEqual(response.status_code, 302)
self.assertIn("accounts.google.com", response["Location"])
self.assertIn("state=", response["Location"])
@patch("apps.users.api.views.exchange_code_for_google_profile")
def test_google_callback_redirects_with_authenticated_flow_for_linked_account(
self,
exchange_code_for_google_profile,
):
exchange_code_for_google_profile.return_value = type(
"Profile",
(),
{
"provider_user_id": "google-sub-1",
"email": "linked@example.com",
"email_verified": True,
"first_name": "Google",
"last_name": "Linked",
"avatar_url": "https://example.com/avatar.png",
},
)()
UserSocialAccount.objects.create(
user=self.user,
provider=UserSocialAccount.ProviderType.GOOGLE,
provider_user_id="google-sub-1",
email="linked@example.com",
email_verified=True,
avatar_url="https://example.com/avatar.png",
)
start_response = self.client.get("/api/users/oauth/google/start/")
state = start_response["Location"].split("state=", 1)[1].split("&", 1)[0]
response = self.client.get(
f"/api/users/oauth/google/callback/?state={state}&code=google-code",
)
self.assertEqual(response.status_code, 302)
self.assertIn("/auth/google/callback?flow=", response["Location"])
flow = response["Location"].split("flow=", 1)[1]
flow_response = self.client.get(f"/api/users/oauth/google/flow/?flow={flow}")
self.assertEqual(flow_response.status_code, 200)
self.assertEqual(flow_response.data["status"], "authenticated")
self.assertIn("access", flow_response.data)
self.assertIn("refresh", flow_response.data)
@patch("apps.users.api.views.exchange_code_for_google_profile")
def test_google_callback_redirects_with_mobile_collection_flow_for_new_account(
self,
exchange_code_for_google_profile,
):
exchange_code_for_google_profile.return_value = type(
"Profile",
(),
{
"provider_user_id": "google-sub-2",
"email": "new@example.com",
"email_verified": True,
"first_name": "New",
"last_name": "User",
"avatar_url": "https://example.com/new-avatar.png",
},
)()
start_response = self.client.get("/api/users/oauth/google/start/")
state = start_response["Location"].split("state=", 1)[1].split("&", 1)[0]
response = self.client.get(
f"/api/users/oauth/google/callback/?state={state}&code=google-code",
)
flow = response["Location"].split("flow=", 1)[1]
flow_response = self.client.get(f"/api/users/oauth/google/flow/?flow={flow}")
self.assertEqual(flow_response.status_code, 200)
self.assertEqual(flow_response.data["status"], "collect_mobile")
self.assertEqual(flow_response.data["email"], "new@example.com")
@patch("apps.users.services.google_oauth.generate_and_send_otp")
def test_google_complete_existing_mobile_moves_flow_to_claim_required(self, generate_and_send_otp):
cache.set(
"google_oauth_flow:test-flow",
{
"status": "collect_mobile",
"google_profile": {
"provider_user_id": "google-sub-3",
"email": "existing@example.com",
"email_verified": True,
"first_name": "Existing",
"last_name": "User",
"avatar_url": "https://example.com/existing.png",
},
"email": "existing@example.com",
"first_name": "Existing",
"last_name": "User",
"avatar_url": "https://example.com/existing.png",
},
900,
)
response = self.client.post(
"/api/users/oauth/google/complete/",
{"flow": "test-flow", "mobile": self.user.mobile},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "claim_required")
generate_and_send_otp.assert_called_once_with(self.user.mobile, "login")
def test_google_complete_new_mobile_creates_user_and_link(self):
cache.set(
"google_oauth_flow:new-flow",
{
"status": "collect_mobile",
"google_profile": {
"provider_user_id": "google-sub-4",
"email": "created@example.com",
"email_verified": True,
"first_name": "Created",
"last_name": "User",
"avatar_url": "https://example.com/created.png",
},
"email": "created@example.com",
"first_name": "Created",
"last_name": "User",
"avatar_url": "https://example.com/created.png",
},
900,
)
response = self.client.post(
"/api/users/oauth/google/complete/",
{"flow": "new-flow", "mobile": "09125550009"},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "authenticated")
created_user = User.objects.get(mobile="09125550009")
self.assertFalse(created_user.has_usable_password())
self.assertTrue(
UserSocialAccount.objects.filter(
user=created_user,
provider=UserSocialAccount.ProviderType.GOOGLE,
provider_user_id="google-sub-4",
).exists()
)
@patch("apps.users.api.views.send_google_claim_otp")
def test_google_claim_send_otp_endpoint_dispatches(self, send_google_claim_otp):
send_google_claim_otp.return_value = {"detail": "Verification code sent successfully."}
response = self.client.post(
"/api/users/oauth/google/claim/send-otp/",
{"flow": "claim-flow"},
format="json",
)
self.assertEqual(response.status_code, 200)
send_google_claim_otp.assert_called_once_with("claim-flow")
@patch("apps.users.api.views.verify_google_claim")
def test_google_claim_verify_returns_tokens(self, verify_google_claim):
verify_google_claim.return_value = {"status": "authenticated", "access": "a", "refresh": "r"}
response = self.client.post(
"/api/users/oauth/google/claim/verify/",
{"flow": "claim-flow", "code": "12345"},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["access"], "a")
verify_google_claim.assert_called_once_with(flow="claim-flow", code="12345")

View File

@@ -0,0 +1,149 @@
from unittest.mock import Mock, patch
from django.test import TestCase
from rest_framework.exceptions import ValidationError
from rest_framework_simplejwt.tokens import RefreshToken
from apps.users.models import LoginAttempt, User
from apps.users.services.auth import (
change_password,
generate_and_send_otp,
login_with_otp,
login_with_password,
logout_user,
register_user_with_otp,
register_user_with_password,
reset_password_with_otp,
)
class FakeRedisConnection:
def __init__(self):
self.store = {}
def get(self, key):
return self.store.get(key)
def setex(self, key, timeout, value):
self.store[key] = str(value).encode("utf-8")
def delete(self, key):
self.store.pop(key, None)
class AuthServiceTests(TestCase):
def test_register_user_with_password_creates_user_and_tokens(self):
tokens = register_user_with_password("09120000001", "secret123")
self.assertTrue(User.objects.filter(mobile="09120000001").exists())
self.assertIn("access", tokens)
self.assertIn("refresh", tokens)
@patch("apps.users.services.auth.send_verification_sms.delay")
@patch("apps.users.services.auth.get_redis_connection")
def test_generate_and_send_otp_stores_code_and_schedules_sms(
self,
get_redis_connection,
send_delay,
):
User.objects.create_user(mobile="09120000002", password="secret123")
fake_redis = FakeRedisConnection()
get_redis_connection.return_value = fake_redis
generate_and_send_otp("09120000002", "login")
self.assertIn("verification_code:09120000002", fake_redis.store)
send_delay.assert_called_once()
@patch("apps.users.services.auth.record_login_attempt")
def test_login_with_password_records_success(self, record_login_attempt):
user = User.objects.create_user(mobile="09120000003", password="secret123")
tokens = login_with_password("09120000003", "secret123", request=None)
self.assertIn("access", tokens)
record_login_attempt.assert_called_once_with(
None,
user,
LoginAttempt.StatusType.SUCCESS,
)
@patch("apps.users.services.auth.record_login_attempt")
def test_login_with_password_rejects_invalid_password(self, record_login_attempt):
User.objects.create_user(mobile="09120000004", password="secret123")
with self.assertRaises(ValidationError):
login_with_password("09120000004", "wrong-password", request=None)
record_login_attempt.assert_called_once()
@patch("apps.users.services.auth.record_login_attempt")
@patch("apps.users.services.auth.get_redis_connection")
def test_login_with_otp_creates_user_and_consumes_code(
self,
get_redis_connection,
record_login_attempt,
):
fake_redis = FakeRedisConnection()
fake_redis.setex("verification_code:09120000005", 120, "12345")
get_redis_connection.return_value = fake_redis
tokens = login_with_otp("09120000005", "12345", request=None)
self.assertTrue(User.objects.filter(mobile="09120000005").exists())
self.assertIn("access", tokens)
self.assertNotIn("verification_code:09120000005", fake_redis.store)
record_login_attempt.assert_called_once()
@patch("apps.users.services.auth.get_redis_connection")
def test_register_user_with_otp_verifies_code_and_marks_user_verified(
self,
get_redis_connection,
):
fake_redis = FakeRedisConnection()
fake_redis.setex("verification_code:09120000006", 120, "12345")
get_redis_connection.return_value = fake_redis
tokens = register_user_with_otp(
mobile="09120000006",
code="12345",
password="secret123",
first_name="OTP",
last_name="User",
)
user = User.objects.get(mobile="09120000006")
self.assertTrue(user.is_verified)
self.assertTrue(user.check_password("secret123"))
self.assertIn("refresh", tokens)
@patch("apps.users.services.auth.get_redis_connection")
def test_reset_password_with_otp_updates_password(self, get_redis_connection):
user = User.objects.create_user(mobile="09120000007", password="oldsecret")
fake_redis = FakeRedisConnection()
fake_redis.setex("verification_code:09120000007", 120, "12345")
get_redis_connection.return_value = fake_redis
reset_password_with_otp("09120000007", "12345", "newsecret")
user.refresh_from_db()
self.assertTrue(user.check_password("newsecret"))
self.assertNotIn("verification_code:09120000007", fake_redis.store)
def test_change_password_updates_existing_user_password(self):
user = User.objects.create_user(mobile="09120000008", password="oldsecret")
change_password(user, "oldsecret", "newsecret")
user.refresh_from_db()
self.assertTrue(user.check_password("newsecret"))
self.assertIsNotNone(user.password_updated_at)
def test_logout_user_blacklists_refresh_token(self):
user = User.objects.create_user(mobile="09120000009", password="secret123")
refresh = str(RefreshToken.for_user(user))
logout_user(refresh)
with self.assertRaises(ValidationError):
logout_user(refresh)

View File

@@ -1,18 +1,18 @@
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework.test import APITestCase
from apps.users.models import User
def test_profile_picture_delete_returns_profile_payload(db):
class ProfilePictureApiTests(APITestCase):
def test_profile_picture_delete_returns_profile_payload(self):
user = User.objects.create_user(mobile="09120000000", password="secret123")
client = APIClient()
client.force_authenticate(user=user)
self.client.force_authenticate(user=user)
response = client.delete("/api/users/profile/picture/")
response = self.client.delete("/api/users/profile/picture/")
assert response.status_code == status.HTTP_200_OK
assert response.data["profile_picture"] is None
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsNone(response.data["profile_picture"])
user.refresh_from_db()
assert not user.profile_picture
self.assertFalse(user.profile_picture)

View File

@@ -1,13 +1,65 @@
from apps.users.tasks import send_verification_sms
from unittest.mock import Mock, patch
from django.test import TestCase
from apps.users.tasks import _send_sms, send_verification_sms
def test_send_verification_sms_skips_real_delivery_without_api_key(settings):
settings.SMS_APIKEY = ""
class UserTaskTests(TestCase):
def test_send_verification_sms_skips_real_delivery_without_api_key(self):
with self.settings(SMS_APIKEY=""):
result = send_verification_sms("09123456789", "12345")
assert result == {
self.assertEqual(
result,
{
"mobile": "09123456789",
"code": "12345",
"sent": False,
}
},
)
@patch("apps.users.tasks._send_sms")
def test_send_verification_sms_calls_sender_when_api_key_exists(self, send_sms):
send_sms.return_value = Mock(status_code=200)
with self.settings(SMS_APIKEY="configured-key"):
send_verification_sms("09123456789", "12345")
send_sms.assert_called_once_with(
"09123456789",
570574,
variables=[{"name": "OTP", "value": "12345"}],
)
@patch("apps.users.tasks._send_sms", return_value=None)
def test_send_verification_sms_raises_when_delivery_fails(self, send_sms):
with self.settings(SMS_APIKEY="configured-key"):
with self.assertRaises(Exception):
send_verification_sms("09123456789", "12345")
send_sms.assert_called_once()
@patch("apps.users.tasks.requests.post")
def test_send_sms_posts_verify_payload(self, requests_post):
response = Mock(status_code=200, text="ok")
response.json.return_value = {"status": "1"}
requests_post.return_value = response
with self.settings(SMS_APIKEY="configured-key"):
result = _send_sms(
"09123456789",
570574,
variables=[{"name": "OTP", "value": "12345"}],
)
self.assertEqual(result, response)
requests_post.assert_called_once()
self.assertEqual(
requests_post.call_args.kwargs["json"],
{
"mobile": "09123456789",
"templateId": 570574,
"parameters": [{"name": "OTP", "value": "12345"}],
},
)

View File

@@ -0,0 +1,36 @@
from django.test import RequestFactory, TestCase
from apps.users.models import LoginAttempt, User
from apps.users.utils import _get_ip, record_login_attempt
class UserUtilsTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(mobile="09120000051", password="secret123")
def setUp(self):
self.factory = RequestFactory()
def test_get_ip_returns_none_without_request(self):
self.assertIsNone(_get_ip(None))
def test_get_ip_prefers_forwarded_header(self):
request = self.factory.get("/", HTTP_X_FORWARDED_FOR="1.1.1.1, 2.2.2.2")
self.assertEqual(_get_ip(request), "1.1.1.1")
def test_get_ip_falls_back_to_remote_addr(self):
request = self.factory.get("/", REMOTE_ADDR="3.3.3.3")
self.assertEqual(_get_ip(request), "3.3.3.3")
def test_record_login_attempt_persists_attempt(self):
request = self.factory.get("/", REMOTE_ADDR="4.4.4.4")
record_login_attempt(request, user=self.user, status=LoginAttempt.StatusType.SUCCESS)
attempt = LoginAttempt.objects.get()
self.assertEqual(attempt.user, self.user)
self.assertEqual(attempt.status, LoginAttempt.StatusType.SUCCESS)
self.assertEqual(attempt.ip_address, "4.4.4.4")

View File

@@ -42,6 +42,17 @@ from apps.workspaces.services import (
update_workspace_user_rate,
)
from core.paginations.limit_offset import CustomLimitOffsetPagination
from core.services.cache import (
CACHE_NAMESPACE_PRICE_UNITS,
CACHE_NAMESPACE_WORKSPACE_MEMBERSHIPS,
CACHE_NAMESPACE_WORKSPACE_RATES,
get_namespace_version,
get_or_set_cache_payload,
)
REFERENCE_CACHE_TTL_SECONDS = 60 * 5
PRICE_UNITS_CACHE_TTL_SECONDS = 60 * 60
class WorkspaceViewSet(ModelViewSet):
@@ -129,7 +140,15 @@ class WorkspaceMembershipViewSet(ModelViewSet):
status=status.HTTP_403_FORBIDDEN,
)
return super().list(request, *args, **kwargs)
payload = get_or_set_cache_payload(
CACHE_NAMESPACE_WORKSPACE_MEMBERSHIPS,
ttl_seconds=REFERENCE_CACHE_TTL_SECONDS,
builder=lambda: super(WorkspaceMembershipViewSet, self).list(request, *args, **kwargs).data,
user_id=request.user.id,
workspace_id=workspace_id,
params=request.query_params,
)
return Response(payload)
def create(self, request, *args, **kwargs):
"""
@@ -271,6 +290,16 @@ class PriceUnitViewSet(ModelViewSet):
def get_queryset(self):
return PriceUnit.objects.filter(is_deleted=False)
def list(self, request, *args, **kwargs):
payload = get_or_set_cache_payload(
CACHE_NAMESPACE_PRICE_UNITS,
ttl_seconds=PRICE_UNITS_CACHE_TTL_SECONDS,
builder=lambda: super(PriceUnitViewSet, self).list(request, *args, **kwargs).data,
user_id=request.user.id,
params=request.query_params,
)
return Response(payload)
class WorkspaceUserRateViewSet(ModelViewSet):
serializer_class = WorkspaceUserRateSerializer
@@ -310,7 +339,18 @@ class WorkspaceUserRateViewSet(ModelViewSet):
)
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
self._ensure_manage_access(request.user, workspace)
return super().list(request, *args, **kwargs)
payload = get_or_set_cache_payload(
CACHE_NAMESPACE_WORKSPACE_RATES,
ttl_seconds=REFERENCE_CACHE_TTL_SECONDS,
builder=lambda: super(WorkspaceUserRateViewSet, self).list(request, *args, **kwargs).data,
user_id=request.user.id,
workspace_id=workspace_id,
params=request.query_params,
extra_versions={
CACHE_NAMESPACE_PRICE_UNITS: get_namespace_version(CACHE_NAMESPACE_PRICE_UNITS),
},
)
return Response(payload)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.12 on 2026-04-30 12:23
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workspaces', '0006_workspace_thumbnail'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='workspacemembership',
index=models.Index(fields=['workspace', 'is_active', 'user'], name='membership_ws_active_user_idx'),
),
]

View File

@@ -80,6 +80,7 @@ class WorkspaceMembership(BaseModel):
indexes = [
models.Index(fields=["workspace"], name="membership_workspace_idx"),
models.Index(fields=["user"], name="membership_user_idx"),
models.Index(fields=["workspace", "is_active", "user"], name="membership_ws_active_user_idx"),
]
constraints = [
models.UniqueConstraint(

View File

@@ -1,7 +1,19 @@
from django.db.models.signals import post_save
from django.db.models.signals import m2m_changed, post_delete, post_save
from django.dispatch import receiver
from apps.clients.models import Client
from apps.projects.models import Project, ProjectRate, ProjectUserRate
from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry
from apps.workspaces.models import Workspace, WorkspaceMembership
from apps.workspaces.models import PriceUnit, WorkspaceUserRate
from core.services.cache import (
CACHE_NAMESPACE_PRICE_UNITS,
CACHE_NAMESPACE_REPORTS,
CACHE_NAMESPACE_WORKSPACE_MEMBERSHIPS,
CACHE_NAMESPACE_WORKSPACE_RATES,
bump_namespace_version,
)
@receiver(post_save, sender=Workspace)
@@ -12,3 +24,65 @@ def create_owner_membership(sender, instance, created, **kwargs):
user=instance.owner,
role=WorkspaceMembership.Role.OWNER,
)
def _bump_workspace_reports(instance):
workspace_id = getattr(instance, "workspace_id", None)
if not workspace_id and hasattr(instance, "project"):
workspace_id = getattr(instance.project, "workspace_id", None)
if workspace_id:
bump_namespace_version(CACHE_NAMESPACE_REPORTS, str(workspace_id))
def _bump_workspace_memberships(instance):
workspace_id = getattr(instance, "workspace_id", None)
if workspace_id:
bump_namespace_version(CACHE_NAMESPACE_WORKSPACE_MEMBERSHIPS, str(workspace_id))
def _bump_workspace_rates(instance):
workspace_id = getattr(instance, "workspace_id", None)
if workspace_id:
bump_namespace_version(CACHE_NAMESPACE_WORKSPACE_RATES, str(workspace_id))
@receiver(post_save, sender=TimeEntry)
@receiver(post_delete, sender=TimeEntry)
@receiver(post_save, sender=Project)
@receiver(post_delete, sender=Project)
@receiver(post_save, sender=Client)
@receiver(post_delete, sender=Client)
@receiver(post_save, sender=Tag)
@receiver(post_delete, sender=Tag)
@receiver(post_save, sender=ProjectRate)
@receiver(post_delete, sender=ProjectRate)
@receiver(post_save, sender=ProjectUserRate)
@receiver(post_delete, sender=ProjectUserRate)
def invalidate_workspace_report_cache(sender, instance, **kwargs):
_bump_workspace_reports(instance)
@receiver(m2m_changed, sender=TimeEntry.tags.through)
def invalidate_workspace_report_cache_for_tags(sender, instance, action, **kwargs):
if action in {"post_add", "post_remove", "post_clear"}:
_bump_workspace_reports(instance)
@receiver(post_save, sender=WorkspaceMembership)
@receiver(post_delete, sender=WorkspaceMembership)
def invalidate_workspace_membership_caches(sender, instance, **kwargs):
_bump_workspace_memberships(instance)
_bump_workspace_reports(instance)
@receiver(post_save, sender=WorkspaceUserRate)
@receiver(post_delete, sender=WorkspaceUserRate)
def invalidate_workspace_rate_caches(sender, instance, **kwargs):
_bump_workspace_rates(instance)
_bump_workspace_reports(instance)
@receiver(post_save, sender=PriceUnit)
@receiver(post_delete, sender=PriceUnit)
def invalidate_price_unit_cache(sender, instance, **kwargs):
bump_namespace_version(CACHE_NAMESPACE_PRICE_UNITS)

View File

@@ -0,0 +1,191 @@
from types import SimpleNamespace
from django.core.cache import cache
from django.test import TestCase
from rest_framework.test import APITestCase
from apps.users.models import User
from apps.workspaces.api.permissions import (
CanWorkspaceManageMembers,
IsWorkspaceAdmin,
IsWorkspaceMember,
IsWorkspaceOwner,
)
from apps.workspaces.models import Workspace, WorkspaceMembership
class WorkspacePermissionTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(mobile="09127770021", password="secret123")
cls.admin = User.objects.create_user(mobile="09127770022", password="secret123")
cls.member = User.objects.create_user(mobile="09127770023", password="secret123")
cls.guest = User.objects.create_user(mobile="09127770024", password="secret123")
cls.outsider = User.objects.create_user(mobile="09127770025", password="secret123")
cls.workspace = Workspace.objects.create(name="Workspace Perms", owner=cls.owner)
cls.admin_membership = WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.admin,
role=WorkspaceMembership.Role.ADMIN,
is_active=True,
)
cls.member_membership = WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
cls.guest_membership = WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.guest,
role=WorkspaceMembership.Role.GUEST,
is_active=True,
)
cls.workspace_note = SimpleNamespace(workspace=cls.workspace)
def _request_for(self, user):
return SimpleNamespace(user=user)
def test_is_workspace_owner_handles_workspace_and_membership_objects(self):
permission = IsWorkspaceOwner()
self.assertTrue(
permission.has_object_permission(
self._request_for(self.owner),
None,
self.workspace,
)
)
self.assertTrue(
permission.has_object_permission(
self._request_for(self.owner),
None,
self.admin_membership,
)
)
self.assertFalse(
permission.has_object_permission(
self._request_for(self.admin),
None,
self.workspace,
)
)
def test_is_workspace_admin_accepts_workspace_related_objects(self):
permission = IsWorkspaceAdmin()
self.assertTrue(
permission.has_object_permission(
self._request_for(self.admin),
None,
self.workspace,
)
)
self.assertTrue(
permission.has_object_permission(
self._request_for(self.admin),
None,
self.workspace_note,
)
)
self.assertFalse(
permission.has_object_permission(
self._request_for(self.member),
None,
self.workspace,
)
)
def test_is_workspace_member_allows_active_guest_but_not_outsider(self):
permission = IsWorkspaceMember()
self.assertTrue(
permission.has_object_permission(
self._request_for(self.guest),
None,
self.workspace,
)
)
self.assertFalse(
permission.has_object_permission(
self._request_for(self.outsider),
None,
self.workspace,
)
)
def test_can_workspace_manage_members_only_allows_owner_and_admin(self):
permission = CanWorkspaceManageMembers()
self.assertTrue(
permission.has_object_permission(
self._request_for(self.owner),
None,
self.workspace,
)
)
self.assertTrue(
permission.has_object_permission(
self._request_for(self.admin),
None,
self.admin_membership,
)
)
self.assertFalse(
permission.has_object_permission(
self._request_for(self.member),
None,
self.workspace,
)
)
self.assertFalse(
permission.has_object_permission(
self._request_for(self.owner),
None,
object(),
)
)
class WorkspaceMembershipCacheTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(mobile="09127770031", password="secret123")
cls.member = User.objects.create_user(mobile="09127770032", password="secret123")
cls.workspace = Workspace.objects.create(name="Membership Cache", owner=cls.owner)
cls.membership = WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
def setUp(self):
cache.clear()
self.client.force_authenticate(user=self.owner)
def test_membership_list_cache_invalidates_after_membership_save(self):
params = {"workspace": str(self.workspace.id)}
first_response = self.client.get("/api/workspace-memberships/", params)
self.assertEqual(first_response.status_code, 200)
target = next(item for item in first_response.data["items"] if item["id"] == str(self.membership.id))
self.assertEqual(target["role"], WorkspaceMembership.Role.MEMBER)
WorkspaceMembership.objects.filter(id=self.membership.id).update(role=WorkspaceMembership.Role.GUEST)
cached_response = self.client.get("/api/workspace-memberships/", params)
self.assertEqual(cached_response.status_code, 200)
target = next(item for item in cached_response.data["items"] if item["id"] == str(self.membership.id))
self.assertEqual(target["role"], WorkspaceMembership.Role.MEMBER)
self.membership.refresh_from_db()
self.membership.is_active = False
self.membership.save(update_fields=["is_active"])
fresh_response = self.client.get("/api/workspace-memberships/", params)
self.assertEqual(fresh_response.status_code, 200)
target = next(item for item in fresh_response.data["items"] if item["id"] == str(self.membership.id))
self.assertEqual(target["role"], WorkspaceMembership.Role.GUEST)
self.assertFalse(target["is_active"])

View File

@@ -1,8 +1,7 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from rest_framework.test import APIClient
from rest_framework.test import APITestCase
from apps.clients.models import Client
from apps.projects.models import Project
@@ -11,130 +10,128 @@ from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
@pytest.fixture()
def api_client():
return APIClient()
class WorkspaceCapabilityTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.owner = cls._user(1)
cls.admin = cls._user(2)
cls.member = cls._user(3)
cls.guest = cls._user(4)
cls.extra_owner = cls._user(5)
cls.workspace = Workspace.objects.create(name="Ops", description="", owner=cls.owner)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.admin,
role=WorkspaceMembership.Role.ADMIN,
is_active=True,
)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.guest,
role=WorkspaceMembership.Role.GUEST,
is_active=True,
)
cls.project = Project.objects.create(
workspace=cls.workspace,
name="Alpha",
description="",
)
def _user(index: int) -> User:
@staticmethod
def _user(index):
return User.objects.create_user(
mobile=f"091255500{index:02d}",
password="secret123",
first_name=f"User{index}",
)
@pytest.fixture()
def owner(db):
return _user(1)
@pytest.fixture()
def admin(db):
return _user(2)
@pytest.fixture()
def member(db):
return _user(3)
@pytest.fixture()
def guest(db):
return _user(4)
@pytest.fixture()
def extra_owner(db):
return _user(5)
@pytest.fixture()
def workspace(owner, admin, member, guest):
workspace = Workspace.objects.create(name="Ops", description="", owner=owner)
WorkspaceMembership.objects.create(
workspace=workspace,
user=admin,
role=WorkspaceMembership.Role.ADMIN,
is_active=True,
def test_member_is_read_only_for_clients_and_projects(self):
client = Client.objects.create(
workspace=self.workspace,
name="Existing Client",
notes="",
)
WorkspaceMembership.objects.create(
workspace=workspace,
user=member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
WorkspaceMembership.objects.create(
workspace=workspace,
user=guest,
role=WorkspaceMembership.Role.GUEST,
is_active=True,
)
return workspace
self.client.force_authenticate(user=self.member)
@pytest.fixture()
def project(workspace, owner, member):
return Project.objects.create(workspace=workspace, name="Alpha", description="")
def test_member_is_read_only_for_clients_and_projects(api_client, member, workspace, project):
client = Client.objects.create(workspace=workspace, name="Existing Client", notes="")
api_client.force_authenticate(user=member)
client_response = api_client.post(
client_response = self.client.post(
"/api/clients/",
{"workspace_id": str(workspace.id), "name": "Acme", "notes": ""},
{
"workspace_id": str(self.workspace.id),
"name": "Acme",
"notes": "",
},
format="json",
)
update_client_response = api_client.patch(
update_client_response = self.client.patch(
f"/api/clients/{client.id}/",
{"name": "Updated"},
format="json",
)
delete_client_response = api_client.delete(f"/api/clients/{client.id}/")
project_response = api_client.post(
delete_client_response = self.client.delete(f"/api/clients/{client.id}/")
project_response = self.client.post(
"/api/projects/",
{"workspace": str(workspace.id), "name": "Beta", "description": "", "client": None},
{
"workspace": str(self.workspace.id),
"name": "Beta",
"description": "",
"client": None,
},
format="json",
)
update_project_response = api_client.patch(
f"/api/projects/{project.id}/",
update_project_response = self.client.patch(
f"/api/projects/{self.project.id}/",
{"description": "Blocked edit"},
format="json",
)
archive_project_response = api_client.post(f"/api/projects/{project.id}/archive/")
delete_project_response = api_client.delete(f"/api/projects/{project.id}/")
assert client_response.status_code == 403
assert update_client_response.status_code == 403
assert delete_client_response.status_code == 403
assert project_response.status_code == 403
assert update_project_response.status_code == 403
assert archive_project_response.status_code == 403
assert delete_project_response.status_code == 403
archive_project_response = self.client.post(
f"/api/projects/{self.project.id}/archive/"
)
delete_project_response = self.client.delete(f"/api/projects/{self.project.id}/")
self.assertEqual(client_response.status_code, 403)
self.assertEqual(update_client_response.status_code, 403)
self.assertEqual(delete_client_response.status_code, 403)
self.assertEqual(project_response.status_code, 403)
self.assertEqual(update_project_response.status_code, 403)
self.assertEqual(archive_project_response.status_code, 403)
self.assertEqual(delete_project_response.status_code, 403)
def test_member_can_create_tags_and_manage_own_time_entries(api_client, owner, member, workspace):
tag = Tag.objects.create(workspace=workspace, name="Existing", color="#000000")
api_client.force_authenticate(user=member)
def test_member_can_create_tags_and_manage_own_time_entries(self):
tag = Tag.objects.create(
workspace=self.workspace,
name="Existing",
color="#000000",
)
self.client.force_authenticate(user=self.member)
create_tag_response = api_client.post(
create_tag_response = self.client.post(
"/api/tags/",
{"workspace_id": str(workspace.id), "name": "New Tag", "color": "#ffffff"},
{
"workspace_id": str(self.workspace.id),
"name": "New Tag",
"color": "#ffffff",
},
format="json",
)
update_tag_response = api_client.patch(
update_tag_response = self.client.patch(
f"/api/tags/{tag.id}/",
{"name": "Changed"},
format="json",
)
delete_tag_response = api_client.delete(f"/api/tags/{tag.id}/")
delete_tag_response = self.client.delete(f"/api/tags/{tag.id}/")
now = timezone.now()
create_entry_response = api_client.post(
create_entry_response = self.client.post(
"/api/time-entries/",
{
"workspace_id": str(workspace.id),
"workspace_id": str(self.workspace.id),
"start_time": now.isoformat(),
"end_time": (now + timedelta(hours=1)).isoformat(),
"description": "Focus block",
@@ -142,195 +139,249 @@ def test_member_can_create_tags_and_manage_own_time_entries(api_client, owner, m
format="json",
)
assert create_tag_response.status_code == 201
assert update_tag_response.status_code == 403
assert delete_tag_response.status_code == 403
assert create_entry_response.status_code == 201
self.assertEqual(create_tag_response.status_code, 201)
self.assertEqual(update_tag_response.status_code, 403)
self.assertEqual(delete_tag_response.status_code, 403)
self.assertEqual(create_entry_response.status_code, 201)
entry_id = create_entry_response.data["id"]
update_entry_response = api_client.patch(
update_entry_response = self.client.patch(
f"/api/time-entries/{entry_id}/",
{"description": "Updated focus block"},
format="json",
)
delete_entry_response = api_client.delete(f"/api/time-entries/{entry_id}/")
delete_entry_response = self.client.delete(f"/api/time-entries/{entry_id}/")
assert update_entry_response.status_code == 200
assert delete_entry_response.status_code == 204
self.assertEqual(update_entry_response.status_code, 200)
self.assertEqual(delete_entry_response.status_code, 204)
def test_guest_is_read_only_for_workspace_resources(self):
Client.objects.create(workspace=self.workspace, name="Visible Client", notes="")
Tag.objects.create(workspace=self.workspace, name="Visible Tag", color="#123456")
def test_guest_is_read_only_for_workspace_resources(api_client, owner, guest, workspace, project):
Client.objects.create(workspace=workspace, name="Visible Client", notes="")
Tag.objects.create(workspace=workspace, name="Visible Tag", color="#123456")
self.client.force_authenticate(user=self.guest)
api_client.force_authenticate(user=guest)
list_clients_response = api_client.get(f"/api/clients/?workspace={workspace.id}")
list_projects_response = api_client.get(f"/api/projects/?workspace={workspace.id}")
create_tag_response = api_client.post(
list_clients_response = self.client.get(
f"/api/clients/?workspace={self.workspace.id}"
)
list_projects_response = self.client.get(
f"/api/projects/?workspace={self.workspace.id}"
)
create_tag_response = self.client.post(
"/api/tags/",
{"workspace_id": str(workspace.id), "name": "Blocked", "color": "#ffffff"},
{
"workspace_id": str(self.workspace.id),
"name": "Blocked",
"color": "#ffffff",
},
format="json",
)
create_entry_response = api_client.post(
create_entry_response = self.client.post(
"/api/time-entries/",
{
"workspace_id": str(workspace.id),
"workspace_id": str(self.workspace.id),
"start_time": timezone.now().isoformat(),
"description": "Blocked guest entry",
},
format="json",
)
edit_project_response = api_client.patch(
f"/api/projects/{project.id}/",
edit_project_response = self.client.patch(
f"/api/projects/{self.project.id}/",
{"description": "Blocked"},
format="json",
)
assert list_clients_response.status_code == 200
assert list_projects_response.status_code == 200
assert create_tag_response.status_code == 403
assert create_entry_response.status_code == 403
assert edit_project_response.status_code == 403
self.assertEqual(list_clients_response.status_code, 200)
self.assertEqual(list_projects_response.status_code, 200)
self.assertEqual(create_tag_response.status_code, 403)
self.assertEqual(create_entry_response.status_code, 403)
self.assertEqual(edit_project_response.status_code, 403)
def test_member_cannot_edit_project(self):
self.client.force_authenticate(user=self.member)
def test_member_cannot_edit_project(api_client, member, project):
api_client.force_authenticate(user=member)
response = api_client.patch(
f"/api/projects/{project.id}/",
response = self.client.patch(
f"/api/projects/{self.project.id}/",
{"description": "Still blocked"},
format="json",
)
assert response.status_code == 403
self.assertEqual(response.status_code, 403)
def test_member_can_list_workspace_members_with_restricted_user_fields(self):
self.client.force_authenticate(user=self.member)
def test_member_can_list_workspace_members_with_restricted_user_fields(api_client, member, workspace):
api_client.force_authenticate(user=member)
response = self.client.get(
f"/api/workspace-memberships/?workspace={self.workspace.id}"
)
response = api_client.get(f"/api/workspace-memberships/?workspace={workspace.id}")
assert response.status_code == 200
payload = response.data.get("items", response.data) if isinstance(response.data, dict) else response.data
assert len(payload) >= 1
self.assertEqual(response.status_code, 200)
payload = (
response.data.get("items", response.data)
if isinstance(response.data, dict)
else response.data
)
self.assertGreaterEqual(len(payload), 1)
first_user = payload[0]["user"]
assert "mobile" not in first_user
assert "email" not in first_user
self.assertNotIn("mobile", first_user)
self.assertNotIn("email", first_user)
def test_owner_can_list_workspace_members_with_full_user_fields(self):
self.client.force_authenticate(user=self.owner)
def test_owner_can_list_workspace_members_with_full_user_fields(api_client, owner, workspace):
api_client.force_authenticate(user=owner)
response = self.client.get(
f"/api/workspace-memberships/?workspace={self.workspace.id}"
)
response = api_client.get(f"/api/workspace-memberships/?workspace={workspace.id}")
assert response.status_code == 200
payload = response.data.get("items", response.data) if isinstance(response.data, dict) else response.data
assert len(payload) >= 1
self.assertEqual(response.status_code, 200)
payload = (
response.data.get("items", response.data)
if isinstance(response.data, dict)
else response.data
)
self.assertGreaterEqual(len(payload), 1)
first_user = payload[0]["user"]
assert "mobile" in first_user
self.assertIn("mobile", first_user)
def test_admin_cannot_change_owner_membership_but_canonical_owner_can(
api_client, owner, admin, extra_owner, workspace
):
def test_admin_cannot_change_owner_membership_but_canonical_owner_can(self):
extra_owner_membership = WorkspaceMembership.objects.create(
workspace=workspace,
user=extra_owner,
workspace=self.workspace,
user=self.extra_owner,
role=WorkspaceMembership.Role.OWNER,
is_active=True,
)
api_client.force_authenticate(user=admin)
admin_response = api_client.patch(
self.client.force_authenticate(user=self.admin)
admin_response = self.client.patch(
f"/api/workspace-memberships/{extra_owner_membership.id}/",
{"role": WorkspaceMembership.Role.ADMIN},
format="json",
)
api_client.force_authenticate(user=owner)
owner_response = api_client.patch(
self.client.force_authenticate(user=self.owner)
owner_response = self.client.patch(
f"/api/workspace-memberships/{extra_owner_membership.id}/",
{"role": WorkspaceMembership.Role.ADMIN},
format="json",
)
assert admin_response.status_code == 403
assert owner_response.status_code == 200
self.assertEqual(admin_response.status_code, 403)
self.assertEqual(owner_response.status_code, 200)
def test_admin_cannot_add_or_change_admin_memberships(self):
admin_membership = WorkspaceMembership.objects.get(
workspace=self.workspace,
user=self.admin,
is_deleted=False,
)
def test_admin_cannot_add_or_change_admin_memberships(api_client, owner, admin, member, workspace):
admin_membership = WorkspaceMembership.objects.get(workspace=workspace, user=admin, is_deleted=False)
api_client.force_authenticate(user=admin)
create_response = api_client.post(
self.client.force_authenticate(user=self.admin)
create_response = self.client.post(
"/api/workspace-memberships/",
{
"workspace": str(workspace.id),
"user": str(member.id),
"workspace": str(self.workspace.id),
"user": str(self.member.id),
"role": WorkspaceMembership.Role.ADMIN,
},
format="json",
)
update_response = api_client.patch(
update_response = self.client.patch(
f"/api/workspace-memberships/{admin_membership.id}/",
{"role": WorkspaceMembership.Role.MEMBER},
format="json",
)
delete_response = api_client.delete(f"/api/workspace-memberships/{admin_membership.id}/")
delete_response = self.client.delete(
f"/api/workspace-memberships/{admin_membership.id}/"
)
assert create_response.status_code == 403
assert update_response.status_code == 403
assert delete_response.status_code == 403
self.assertEqual(create_response.status_code, 403)
self.assertEqual(update_response.status_code, 403)
self.assertEqual(delete_response.status_code, 403)
def test_admin_can_delete_only_owned_clients_tags_and_projects(api_client, owner, admin, workspace):
api_client.force_authenticate(user=owner)
owner_client_response = api_client.post(
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(
"/api/clients/",
{"workspace_id": str(workspace.id), "name": "Owner Client", "notes": ""},
{
"workspace_id": str(self.workspace.id),
"name": "Owner Client",
"notes": "",
},
format="json",
)
owner_tag_response = api_client.post(
owner_tag_response = self.client.post(
"/api/tags/",
{"workspace_id": str(workspace.id), "name": "Owner Tag", "color": "#123456"},
{
"workspace_id": str(self.workspace.id),
"name": "Owner Tag",
"color": "#123456",
},
format="json",
)
owner_project_response = api_client.post(
owner_project_response = self.client.post(
"/api/projects/",
{"workspace": str(workspace.id), "name": "Owner Project", "description": "", "client": None},
{
"workspace": str(self.workspace.id),
"name": "Owner Project",
"description": "",
"client": None,
},
format="json",
)
api_client.force_authenticate(user=admin)
admin_client_response = api_client.post(
self.client.force_authenticate(user=self.admin)
admin_client_response = self.client.post(
"/api/clients/",
{"workspace_id": str(workspace.id), "name": "Admin Client", "notes": ""},
{
"workspace_id": str(self.workspace.id),
"name": "Admin Client",
"notes": "",
},
format="json",
)
admin_tag_response = api_client.post(
admin_tag_response = self.client.post(
"/api/tags/",
{"workspace_id": str(workspace.id), "name": "Admin Tag", "color": "#654321"},
{
"workspace_id": str(self.workspace.id),
"name": "Admin Tag",
"color": "#654321",
},
format="json",
)
admin_project_response = api_client.post(
admin_project_response = self.client.post(
"/api/projects/",
{"workspace": str(workspace.id), "name": "Admin Project", "description": "", "client": None},
{
"workspace": str(self.workspace.id),
"name": "Admin Project",
"description": "",
"client": None,
},
format="json",
)
delete_owner_client = api_client.delete(f"/api/clients/{owner_client_response.data['id']}/")
delete_owner_tag = api_client.delete(f"/api/tags/{owner_tag_response.data['id']}/")
delete_owner_project = api_client.delete(f"/api/projects/{owner_project_response.data['id']}/")
delete_owner_client = self.client.delete(
f"/api/clients/{owner_client_response.data['id']}/"
)
delete_owner_tag = self.client.delete(
f"/api/tags/{owner_tag_response.data['id']}/"
)
delete_owner_project = self.client.delete(
f"/api/projects/{owner_project_response.data['id']}/"
)
delete_admin_client = api_client.delete(f"/api/clients/{admin_client_response.data['id']}/")
delete_admin_tag = api_client.delete(f"/api/tags/{admin_tag_response.data['id']}/")
delete_admin_project = api_client.delete(f"/api/projects/{admin_project_response.data['id']}/")
delete_admin_client = self.client.delete(
f"/api/clients/{admin_client_response.data['id']}/"
)
delete_admin_tag = self.client.delete(
f"/api/tags/{admin_tag_response.data['id']}/"
)
delete_admin_project = self.client.delete(
f"/api/projects/{admin_project_response.data['id']}/"
)
assert delete_owner_client.status_code == 403
assert delete_owner_tag.status_code == 403
assert delete_owner_project.status_code in {403, 404}
assert delete_admin_client.status_code == 204
assert delete_admin_tag.status_code == 204
assert delete_admin_project.status_code == 204
self.assertEqual(delete_owner_client.status_code, 403)
self.assertEqual(delete_owner_tag.status_code, 403)
self.assertIn(delete_owner_project.status_code, {403, 404})
self.assertEqual(delete_admin_client.status_code, 204)
self.assertEqual(delete_admin_tag.status_code, 204)
self.assertEqual(delete_admin_project.status_code, 204)

View File

@@ -1,128 +1,254 @@
from decimal import Decimal
import pytest
from rest_framework.test import APIClient
from django.core.cache import cache
from django.test import TestCase
from rest_framework.test import APITestCase
from apps.projects.models import Project
from apps.time_entries.services.rates import resolve_rate
from apps.users.models import User
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
from apps.workspaces.models import (
PriceUnit,
Workspace,
WorkspaceMembership,
WorkspaceUserRate,
)
from apps.workspaces.services.rates import (
update_workspace_user_rate,
upsert_workspace_user_rate,
)
@pytest.fixture()
def api_client():
return APIClient()
class WorkspaceRateTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(mobile="09127770001", password="secret123")
cls.admin = User.objects.create_user(mobile="09127770002", password="secret123")
cls.member = User.objects.create_user(mobile="09127770003", password="secret123")
cls.workspace = Workspace.objects.create(name="Rates", owner=cls.owner)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.admin,
role=WorkspaceMembership.Role.ADMIN,
is_active=True,
)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
cls.project = Project.objects.create(workspace=cls.workspace, name="Billing")
@pytest.fixture()
def owner(db):
return User.objects.create_user(mobile="09127770001", password="secret123")
PriceUnit.objects.create(
code="USD",
name="US Dollar",
local_name="Dollar",
symbol="$",
)
PriceUnit.objects.create(
code="EUR",
name="Euro",
local_name="Euro",
symbol="EUR",
)
def setUp(self):
cache.clear()
@pytest.fixture()
def admin(db):
return User.objects.create_user(mobile="09127770002", password="secret123")
@pytest.fixture()
def member(db):
return User.objects.create_user(mobile="09127770003", password="secret123")
@pytest.fixture()
def workspace(owner, admin, member):
workspace = Workspace.objects.create(name="Rates", owner=owner)
WorkspaceMembership.objects.create(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True)
WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True)
return workspace
@pytest.fixture()
def project(workspace, owner, admin, member):
return Project.objects.create(workspace=workspace, name="Billing")
@pytest.fixture()
def price_units(db):
PriceUnit.objects.create(code="USD", name="US Dollar", local_name="دلار آمریکا", symbol="$")
PriceUnit.objects.create(code="EUR", name="Euro", local_name="یورو", symbol="")
def test_resolve_rate_uses_workspace_user_rate(workspace, project, member):
def test_resolve_rate_uses_workspace_user_rate(self):
WorkspaceUserRate.objects.create(
workspace=workspace,
user=member,
workspace=self.workspace,
user=self.member,
hourly_rate=Decimal("40.00"),
currency="EUR",
effective_from=project.created_at,
effective_from=self.project.created_at,
is_active=True,
)
hourly_rate, currency = resolve_rate(member, project)
hourly_rate, currency = resolve_rate(self.member, self.project)
assert hourly_rate == Decimal("40.00")
assert currency == "EUR"
self.assertEqual(hourly_rate, Decimal("40.00"))
self.assertEqual(currency, "EUR")
def test_resolve_rate_returns_none_when_workspace_rate_is_missing(self):
hourly_rate, currency = resolve_rate(self.member, self.project)
def test_resolve_rate_falls_back_to_workspace_user_rate(workspace, project, member):
WorkspaceUserRate.objects.create(
workspace=workspace,
user=member,
hourly_rate=Decimal("40.00"),
currency="EUR",
effective_from=project.created_at,
is_active=True,
)
self.assertIsNone(hourly_rate)
self.assertEqual(currency, "")
hourly_rate, currency = resolve_rate(member, project)
def test_admin_can_manage_workspace_user_rates(self):
self.client.force_authenticate(user=self.admin)
assert hourly_rate == Decimal("40.00")
assert currency == "EUR"
def test_admin_can_manage_workspace_user_rates(api_client, admin, member, workspace, price_units):
api_client.force_authenticate(user=admin)
create_response = api_client.post(
create_response = self.client.post(
"/api/workspace-user-rates/",
{
"workspace_id": str(workspace.id),
"user_id": str(member.id),
"workspace_id": str(self.workspace.id),
"user_id": str(self.member.id),
"hourly_rate": "35.50",
"currency": "USD",
},
format="json",
)
assert create_response.status_code == 201
self.assertEqual(create_response.status_code, 201)
rate_id = create_response.data["id"]
assert WorkspaceUserRate.objects.filter(id=rate_id, is_deleted=False).exists()
self.assertTrue(
WorkspaceUserRate.objects.filter(id=rate_id, is_deleted=False).exists()
)
update_response = api_client.patch(
update_response = self.client.patch(
f"/api/workspace-user-rates/{rate_id}/",
{"hourly_rate": "42.00"},
format="json",
)
assert update_response.status_code == 200
assert update_response.data["hourly_rate"] == "42.00"
self.assertEqual(update_response.status_code, 200)
self.assertEqual(update_response.data["hourly_rate"], "42.00")
delete_response = api_client.delete(f"/api/workspace-user-rates/{rate_id}/")
assert delete_response.status_code == 204
assert WorkspaceUserRate.all_objects.get(id=rate_id).is_deleted is True
delete_response = self.client.delete(f"/api/workspace-user-rates/{rate_id}/")
self.assertEqual(delete_response.status_code, 204)
self.assertTrue(WorkspaceUserRate.all_objects.get(id=rate_id).is_deleted)
def test_member_cannot_manage_rates(self):
self.client.force_authenticate(user=self.member)
def test_member_cannot_manage_rates(api_client, member, workspace, price_units):
api_client.force_authenticate(user=member)
workspace_response = api_client.post(
response = self.client.post(
"/api/workspace-user-rates/",
{
"workspace_id": str(workspace.id),
"user_id": str(member.id),
"workspace_id": str(self.workspace.id),
"user_id": str(self.member.id),
"hourly_rate": "25.00",
"currency": "USD",
},
format="json",
)
assert workspace_response.status_code == 403
self.assertEqual(response.status_code, 403)
def test_workspace_user_rates_cache_invalidates_after_rate_save(self):
rate = WorkspaceUserRate.objects.create(
workspace=self.workspace,
user=self.member,
hourly_rate=Decimal("30.00"),
currency="USD",
effective_from=self.workspace.created_at,
is_active=True,
)
self.client.force_authenticate(user=self.admin)
first_response = self.client.get(
"/api/workspace-user-rates/",
{"workspace": str(self.workspace.id)},
)
self.assertEqual(first_response.status_code, 200)
self.assertEqual(first_response.data["items"][0]["hourly_rate"], "30.00")
WorkspaceUserRate.objects.filter(id=rate.id).update(hourly_rate=Decimal("45.00"))
cached_response = self.client.get(
"/api/workspace-user-rates/",
{"workspace": str(self.workspace.id)},
)
self.assertEqual(cached_response.status_code, 200)
self.assertEqual(cached_response.data["items"][0]["hourly_rate"], "30.00")
rate.refresh_from_db()
rate.currency = "EUR"
rate.save(update_fields=["currency"])
fresh_response = self.client.get(
"/api/workspace-user-rates/",
{"workspace": str(self.workspace.id)},
)
self.assertEqual(fresh_response.status_code, 200)
self.assertEqual(fresh_response.data["items"][0]["hourly_rate"], "45.00")
self.assertEqual(fresh_response.data["items"][0]["currency"], "EUR")
def test_price_unit_cache_invalidates_after_price_unit_create(self):
self.client.force_authenticate(user=self.owner)
first_response = self.client.get("/api/price-units/")
self.assertEqual(first_response.status_code, 200)
self.assertEqual(first_response.data[0]["name"], "Euro")
self.assertEqual(len(first_response.data), 2)
PriceUnit.objects.filter(code="EUR").update(name="Updated Euro")
cached_response = self.client.get("/api/price-units/")
self.assertEqual(cached_response.status_code, 200)
self.assertEqual(cached_response.data[0]["name"], "Euro")
PriceUnit.objects.create(
code="GBP",
name="British Pound",
local_name="Pound",
symbol="£",
)
fresh_response = self.client.get("/api/price-units/")
self.assertEqual(fresh_response.status_code, 200)
self.assertEqual(len(fresh_response.data), 3)
euro_row = next(item for item in fresh_response.data if item["code"] == "EUR")
self.assertEqual(euro_row["name"], "Updated Euro")
class WorkspaceRateServiceTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(mobile="09127770011", password="secret123")
cls.member = User.objects.create_user(mobile="09127770012", password="secret123")
cls.workspace = Workspace.objects.create(name="Rate Services", owner=cls.owner)
def test_upsert_workspace_user_rate_creates_uppercase_currency_rate(self):
rate = upsert_workspace_user_rate(
self.workspace,
self.member.id,
Decimal("12.50"),
"usd",
)
self.assertEqual(rate.hourly_rate, Decimal("12.50"))
self.assertEqual(rate.currency, "USD")
self.assertTrue(rate.is_active)
def test_upsert_workspace_user_rate_updates_existing_inactive_rate(self):
rate = WorkspaceUserRate.objects.create(
workspace=self.workspace,
user=self.member,
hourly_rate=Decimal("10.00"),
currency="USD",
effective_from=self.workspace.created_at,
is_active=False,
)
updated = upsert_workspace_user_rate(
self.workspace,
self.member.id,
Decimal("20.00"),
"eur",
)
self.assertEqual(updated.id, rate.id)
self.assertEqual(updated.hourly_rate, Decimal("20.00"))
self.assertEqual(updated.currency, "EUR")
self.assertTrue(updated.is_active)
def test_update_workspace_user_rate_updates_only_changed_fields(self):
rate = WorkspaceUserRate.objects.create(
workspace=self.workspace,
user=self.member,
hourly_rate=Decimal("10.00"),
currency="USD",
effective_from=self.workspace.created_at,
is_active=True,
)
updated = update_workspace_user_rate(
rate,
hourly_rate=Decimal("15.00"),
currency="gbp",
)
self.assertEqual(updated.hourly_rate, Decimal("15.00"))
self.assertEqual(updated.currency, "GBP")

View File

@@ -131,6 +131,14 @@ REST_FRAMEWORK = {
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "60/min",
"user": "300/min",
"otp_send_burst": "3/10m",
"otp_send_sustained": "10/day",
"login_password": "5/10m",
"login_otp": "5/10m",
},
"EXCEPTION_HANDLER": "core.exceptions.handlers.exception_handler",
}
@@ -248,5 +256,9 @@ STORAGES = {
SMS_APIKEY = os.getenv("SMS_APIKEY", "")
BASE_URL = os.getenv("BASE_URL", "")
GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID", "")
GOOGLE_OAUTH_CLIENT_SECRET = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET", "")
GOOGLE_OAUTH_REDIRECT_URI = os.getenv("GOOGLE_OAUTH_REDIRECT_URI", "")
GOOGLE_OAUTH_FRONTEND_CALLBACK_URL = os.getenv("GOOGLE_OAUTH_FRONTEND_CALLBACK_URL", "")
from config.services.auditlog import * # noqa: E402,F401,F403

View File

@@ -28,3 +28,4 @@ CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
TEST_RUNNER = "config.test_runner.AppDiscoverRunner"

51
config/test_runner.py Normal file
View File

@@ -0,0 +1,51 @@
from pathlib import Path
from django.apps import apps
from django.test.runner import DiscoverRunner
class AppDiscoverRunner(DiscoverRunner):
def load_tests_for_label(self, label, discover_kwargs):
if label.startswith("apps."):
try:
app_config = apps.get_app_config(label.split(".", 1)[1])
except LookupError:
return super().load_tests_for_label(label, discover_kwargs)
test_dir = Path(app_config.path) / "tests"
if test_dir.exists():
repo_root = Path(__file__).resolve().parent.parent
discover_kwargs = dict(discover_kwargs)
discover_kwargs["top_level_dir"] = str(repo_root)
return self.test_loader.discover(
start_dir=str(test_dir),
**discover_kwargs,
)
return super().load_tests_for_label(label, discover_kwargs)
def build_suite(self, test_labels=None, extra_tests=None, **kwargs):
if test_labels:
return super().build_suite(
test_labels,
extra_tests=extra_tests,
**kwargs,
)
suite = self.test_suite()
repo_root = Path(__file__).resolve().parent.parent
for app_config in apps.get_app_configs():
test_dir = Path(app_config.path) / "tests"
if app_config.name.startswith("apps.") and test_dir.exists():
suite.addTests(
self.test_loader.discover(
start_dir=str(test_dir),
pattern=self.pattern,
top_level_dir=str(repo_root),
)
)
if extra_tests:
suite.addTests(extra_tests)
return suite

View File

@@ -1,11 +1,14 @@
import logging
import traceback
from collections.abc import Iterable
from datetime import timedelta
from typing import Any
from django.conf import settings
from django.utils import timezone
from rest_framework import status as http_status
from rest_framework.exceptions import ErrorDetail
from rest_framework.exceptions import Throttled
from rest_framework.response import Response
from rest_framework.views import exception_handler as drf_exception_handler
@@ -83,7 +86,36 @@ def exception_handler(exc, context) -> Response:
if status_code < 500:
messages = _to_str_list(detail)
payload = _format_payload(messages, status_code)
return Response(payload, status=status_code)
if isinstance(exc, Throttled):
request = context.get("request")
wait = getattr(exc, "wait", None)
retry_after_seconds = None
if wait is not None:
retry_after_seconds = max(int(wait), 0)
elif request is not None:
retry_after_seconds = getattr(request, "_retry_after_seconds", None)
throttle_scope = getattr(request, "_throttle_scope", None) if request else None
payload.update(
{
"code": "throttled",
"scope": throttle_scope,
"retry_after_seconds": retry_after_seconds,
"throttled_until": (
timezone.now() + timedelta(seconds=retry_after_seconds)
).isoformat()
if retry_after_seconds is not None
else None,
}
)
formatted_response = Response(payload, status=status_code)
for header, value in response.headers.items():
formatted_response[header] = value
if isinstance(exc, Throttled) and "Retry-After" not in formatted_response:
request = context.get("request")
retry_after_seconds = getattr(request, "_retry_after_seconds", None) if request else None
if retry_after_seconds is not None:
formatted_response["Retry-After"] = str(max(int(retry_after_seconds), 0))
return formatted_response
traceback_text = traceback.format_exc()
payload = _format_payload(

View File

@@ -0,0 +1 @@

124
core/services/cache.py Normal file
View File

@@ -0,0 +1,124 @@
import hashlib
import json
from collections.abc import Callable, Mapping
from typing import Any
from django.core.cache import cache
CACHE_NAMESPACE_REPORTS = "reports"
CACHE_NAMESPACE_WORKSPACE_MEMBERSHIPS = "workspace-memberships"
CACHE_NAMESPACE_WORKSPACE_RATES = "workspace-rates"
CACHE_NAMESPACE_PRICE_UNITS = "price-units"
_CACHE_VERSION_TTL_SECONDS = 60 * 60 * 24 * 30
def _stringify_value(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def normalize_query_params(params: Any) -> dict[str, list[str]]:
if hasattr(params, "lists"):
raw_items = params.lists()
elif isinstance(params, Mapping):
raw_items = params.items()
else:
raw_items = []
normalized: dict[str, list[str]] = {}
for key, value in raw_items:
if isinstance(value, (list, tuple)):
values = [_stringify_value(item) for item in value if item is not None]
else:
values = [_stringify_value(value)]
normalized[str(key)] = sorted(values)
return dict(sorted(normalized.items()))
def get_namespace_version(namespace: str, workspace_id: str | None = None) -> int:
scope = workspace_id or "global"
cache_key = f"cache-version:{namespace}:{scope}"
version = cache.get(cache_key)
if version is None:
cache.set(cache_key, 1, timeout=_CACHE_VERSION_TTL_SECONDS)
return 1
return int(version)
def bump_namespace_version(namespace: str, workspace_id: str | None = None) -> int:
scope = workspace_id or "global"
cache_key = f"cache-version:{namespace}:{scope}"
version = cache.get(cache_key)
if version is None:
cache.set(cache_key, 2, timeout=_CACHE_VERSION_TTL_SECONDS)
return 2
try:
return int(cache.incr(cache_key))
except ValueError:
next_version = int(version) + 1
cache.set(cache_key, next_version, timeout=_CACHE_VERSION_TTL_SECONDS)
return next_version
def build_cache_key(
namespace: str,
*,
resource: str | None = None,
user_id: Any = None,
workspace_id: Any = None,
params: Any = None,
extra_versions: Mapping[str, int] | None = None,
) -> str:
normalized_params = normalize_query_params(params or {})
params_json = json.dumps(normalized_params, sort_keys=True, separators=(",", ":"))
params_hash = hashlib.md5(params_json.encode("utf-8")).hexdigest()
namespace_version = get_namespace_version(namespace, str(workspace_id) if workspace_id else None)
segments = [
namespace,
f"resource:{resource or 'default'}",
f"v{namespace_version}",
f"user:{user_id or 'anon'}",
f"workspace:{workspace_id or 'global'}",
]
if extra_versions:
for key, value in sorted(extra_versions.items()):
segments.append(f"{key}:v{value}")
segments.append(params_hash)
return ":".join(segments)
def get_or_set_cache_payload(
namespace: str,
*,
ttl_seconds: int,
builder: Callable[[], Any],
resource: str | None = None,
user_id: Any = None,
workspace_id: Any = None,
params: Any = None,
extra_versions: Mapping[str, int] | None = None,
) -> Any:
cache_key = build_cache_key(
namespace,
resource=resource,
user_id=user_id,
workspace_id=workspace_id,
params=params,
extra_versions=extra_versions,
)
payload = cache.get(cache_key)
if payload is not None:
return payload
payload = builder()
cache.set(cache_key, payload, timeout=ttl_seconds)
return payload

View File

@@ -1,3 +0,0 @@
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.test
python_files = tests.py test_*.py *_tests.py

View File

@@ -8,8 +8,7 @@ ipython>=8.25
django-debug-toolbar>=4.4
# Testing
pytest>=8.2
pytest-django>=4.8
coverage>=7.10
factory-boy>=3.3
# Linting & formatting