Compare commits
10 Commits
ec199a0e99
...
0823267544
| Author | SHA1 | Date | |
|---|---|---|---|
| 0823267544 | |||
| df9a183823 | |||
| fb15a16204 | |||
| 99eb4c2594 | |||
| 054bb5a582 | |||
| 08e1793765 | |||
| 3152284cf3 | |||
| 8774a4d4dc | |||
| 204225dd16 | |||
| a2de2a133c |
11
.coveragerc
Normal file
11
.coveragerc
Normal file
@@ -0,0 +1,11 @@
|
||||
[run]
|
||||
branch = True
|
||||
source =
|
||||
apps
|
||||
omit =
|
||||
*/migrations/*
|
||||
*/tests/*
|
||||
|
||||
[report]
|
||||
show_missing = True
|
||||
skip_covered = False
|
||||
@@ -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
193
README.md
Normal 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
|
||||
1
apps/clients/tests/__init__.py
Normal file
1
apps/clients/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
67
apps/clients/tests/test_services.py
Normal file
67
apps/clients/tests/test_services.py
Normal 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)
|
||||
|
||||
125
apps/clients/tests/test_views.py
Normal file
125
apps/clients/tests/test_views.py
Normal 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)
|
||||
@@ -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}§ion=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}§ion=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"])
|
||||
)
|
||||
|
||||
1
apps/notifications/tests/__init__.py
Normal file
1
apps/notifications/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
126
apps/notifications/tests/fakes.py
Normal file
126
apps/notifications/tests/fakes.py
Normal 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
|
||||
@@ -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 tearDown(self):
|
||||
services.redis_client = self.original_redis_client
|
||||
|
||||
@staticmethod
|
||||
def _notifications_for(user):
|
||||
notifications, _ = RedisNotificationStore.list(
|
||||
str(user.id),
|
||||
paginate=False,
|
||||
)
|
||||
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), [])
|
||||
|
||||
@@ -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")
|
||||
|
||||
20
apps/notifications/tests/test_tasks.py
Normal file
20
apps/notifications/tests/test_tasks.py
Normal 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
|
||||
)
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
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")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def second_user(db):
|
||||
return User.objects.create_user(mobile="09122222222", password="secret123")
|
||||
|
||||
def tearDown(self):
|
||||
services.redis_client = self.original_redis_client
|
||||
|
||||
@staticmethod
|
||||
def _read_sse_chunks(response, count):
|
||||
iterator = iter(response.streaming_content)
|
||||
chunks = []
|
||||
@@ -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"]))
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
34
apps/projects/tests/test_permissions.py
Normal file
34
apps/projects/tests/test_permissions.py
Normal 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)
|
||||
|
||||
97
apps/projects/tests/test_services.py
Normal file
97
apps/projects/tests/test_services.py
Normal 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)
|
||||
@@ -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)},
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
105
apps/reports/tests/test_api_views.py
Normal file
105
apps/reports/tests/test_api_views.py
Normal 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"])
|
||||
104
apps/reports/tests/test_exporters.py
Normal file
104
apps/reports/tests/test_exporters.py
Normal 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")
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
1
apps/tags/tests/__init__.py
Normal file
1
apps/tags/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
59
apps/tags/tests/test_services.py
Normal file
59
apps/tags/tests/test_services.py
Normal 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)
|
||||
|
||||
136
apps/tags/tests/test_views.py
Normal file
136
apps/tags/tests/test_views.py
Normal 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)
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
1
apps/time_entries/tests/__init__.py
Normal file
1
apps/time_entries/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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"], [])
|
||||
|
||||
@@ -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
140
apps/users/api/throttles.py
Normal 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"
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
43
apps/users/migrations/0002_usersocialaccount.py
Normal file
43
apps/users/migrations/0002_usersocialaccount.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
333
apps/users/services/google_oauth.py
Normal file
333
apps/users/services/google_oauth.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
1
apps/users/tests/__init__.py
Normal file
1
apps/users/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
592
apps/users/tests/test_api_views.py
Normal file
592
apps/users/tests/test_api_views.py
Normal 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")
|
||||
149
apps/users/tests/test_auth_services.py
Normal file
149
apps/users/tests/test_auth_services.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}],
|
||||
},
|
||||
)
|
||||
|
||||
36
apps/users/tests/test_utils.py
Normal file
36
apps/users/tests/test_utils.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
191
apps/workspaces/tests/test_api_permissions.py
Normal file
191
apps/workspaces/tests/test_api_permissions.py
Normal 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"])
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
51
config/test_runner.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
1
core/services/__init__.py
Normal file
1
core/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
124
core/services/cache.py
Normal file
124
core/services/cache.py
Normal 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
|
||||
@@ -1,3 +0,0 @@
|
||||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = config.settings.test
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user