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
|
||||||
@@ -40,5 +40,9 @@ CELERY_RESULT_BACKEND=
|
|||||||
LANGUAGE_CODE=en-us
|
LANGUAGE_CODE=en-us
|
||||||
TIME_ZONE=Asia/Tehran
|
TIME_ZONE=Asia/Tehran
|
||||||
|
|
||||||
SMS_APIKEY=
|
SMS_APIKEY=
|
||||||
BASE_URL=
|
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,181 +1,161 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import LogEntry
|
||||||
from rest_framework_simplejwt.tokens import AccessToken
|
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.reports.models import ReportExportJob
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class WorkspaceLogViewTests(APITestCase):
|
||||||
def api_client():
|
@classmethod
|
||||||
return APIClient()
|
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
|
||||||
return User.objects.create_user(
|
def _user(index):
|
||||||
mobile=f"093355500{index:02d}",
|
return User.objects.create_user(
|
||||||
password="secret123",
|
mobile=f"093355500{index:02d}",
|
||||||
first_name=f"Log{index}",
|
password="secret123",
|
||||||
last_name="User",
|
first_name=f"Log{index}",
|
||||||
)
|
last_name="User",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _auth_headers(user):
|
||||||
|
token = str(AccessToken.for_user(user))
|
||||||
|
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
||||||
|
|
||||||
@pytest.fixture()
|
def _create_tag(self, user, *, name="Audit Tag"):
|
||||||
def owner(db):
|
return self.client.post(
|
||||||
return _user(1)
|
"/api/tags/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": name,
|
||||||
|
"color": "#123456",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
**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.fixture()
|
owner_response = self.client.get(
|
||||||
def admin(db):
|
f"/api/logs/?workspace={self.workspace.id}",
|
||||||
return _user(2)
|
**self._auth_headers(self.owner),
|
||||||
|
)
|
||||||
|
admin_response = self.client.get(
|
||||||
|
f"/api/logs/?workspace={self.workspace.id}",
|
||||||
|
**self._auth_headers(self.admin),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(owner_response.status_code, 200)
|
||||||
|
self.assertEqual(admin_response.status_code, 200)
|
||||||
|
self.assertEqual(owner_response.data["items"][0]["section"], "tags")
|
||||||
|
|
||||||
@pytest.fixture()
|
def test_member_and_non_member_cannot_list_workspace_logs(self):
|
||||||
def member(db):
|
self._create_tag(self.owner)
|
||||||
return _user(3)
|
|
||||||
|
|
||||||
|
member_response = self.client.get(
|
||||||
|
f"/api/logs/?workspace={self.workspace.id}",
|
||||||
|
**self._auth_headers(self.member),
|
||||||
|
)
|
||||||
|
outsider_response = self.client.get(
|
||||||
|
f"/api/logs/?workspace={self.workspace.id}",
|
||||||
|
**self._auth_headers(self.outsider),
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.fixture()
|
self.assertEqual(member_response.status_code, 403)
|
||||||
def outsider(db):
|
self.assertEqual(outsider_response.status_code, 403)
|
||||||
return _user(4)
|
|
||||||
|
|
||||||
|
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.fixture()
|
log_entry = LogEntry.objects.filter(content_type__app_label="tags").latest(
|
||||||
def workspace(owner, admin, member):
|
"timestamp"
|
||||||
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
|
|
||||||
|
|
||||||
|
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 _auth_headers(user: User) -> dict:
|
def test_logs_support_section_filter_and_detail(self):
|
||||||
token = str(AccessToken.for_user(user))
|
tag_response = self._create_tag(self.owner, name="Filtered Tag")
|
||||||
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
def _create_tag(client: APIClient, user: User, workspace: Workspace, *, name="Audit Tag"):
|
self.assertEqual(list_response.status_code, 200)
|
||||||
return client.post(
|
self.assertTrue(list_response.data["items"])
|
||||||
"/api/tags/",
|
log_id = list_response.data["items"][0]["id"]
|
||||||
{"workspace_id": str(workspace.id), "name": name, "color": "#123456"},
|
|
||||||
format="json",
|
|
||||||
**_auth_headers(user),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
detail_response = self.client.get(
|
||||||
|
f"/api/logs/{log_id}/",
|
||||||
|
**self._auth_headers(self.owner),
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.django_db
|
self.assertEqual(detail_response.status_code, 200)
|
||||||
def test_owner_and_admin_can_list_workspace_logs(api_client, owner, admin, workspace):
|
self.assertEqual(detail_response.data["target"]["name"], "Filtered Tag")
|
||||||
create_response = _create_tag(api_client, owner, workspace)
|
self.assertTrue(detail_response.data["changes"])
|
||||||
assert create_response.status_code == 201
|
|
||||||
|
|
||||||
owner_response = api_client.get(
|
def test_soft_delete_and_actorless_background_logs_are_filtered(self):
|
||||||
f"/api/logs/?workspace={workspace.id}",
|
create_response = self._create_tag(self.owner, name="Delete Me")
|
||||||
**_auth_headers(owner),
|
self.assertEqual(create_response.status_code, 201)
|
||||||
)
|
tag_id = create_response.data["id"]
|
||||||
admin_response = api_client.get(
|
|
||||||
f"/api/logs/?workspace={workspace.id}",
|
|
||||||
**_auth_headers(admin),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert owner_response.status_code == 200
|
delete_response = self.client.delete(
|
||||||
assert admin_response.status_code == 200
|
f"/api/tags/{tag_id}/",
|
||||||
assert owner_response.data["items"][0]["section"] == "tags"
|
**self._auth_headers(self.owner),
|
||||||
|
)
|
||||||
|
self.assertEqual(delete_response.status_code, 204)
|
||||||
|
|
||||||
|
ReportExportJob.objects.create(
|
||||||
|
requesting_user=self.owner,
|
||||||
|
workspace=self.workspace,
|
||||||
|
export_type=ReportExportJob.ExportType.PDF,
|
||||||
|
filters={"workspace": str(self.workspace.id)},
|
||||||
|
status=ReportExportJob.Status.PENDING,
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.django_db
|
response = self.client.get(
|
||||||
def test_member_and_non_member_cannot_list_workspace_logs(api_client, owner, member, outsider, workspace):
|
f"/api/logs/?workspace={self.workspace.id}&event=delete",
|
||||||
_create_tag(api_client, owner, workspace)
|
**self._auth_headers(self.owner),
|
||||||
|
)
|
||||||
member_response = api_client.get(
|
|
||||||
f"/api/logs/?workspace={workspace.id}",
|
|
||||||
**_auth_headers(member),
|
|
||||||
)
|
|
||||||
outsider_response = api_client.get(
|
|
||||||
f"/api/logs/?workspace={workspace.id}",
|
|
||||||
**_auth_headers(outsider),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert member_response.status_code == 403
|
|
||||||
assert outsider_response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@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),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert list_response.status_code == 200
|
|
||||||
assert list_response.data["items"]
|
|
||||||
log_id = list_response.data["items"][0]["id"]
|
|
||||||
|
|
||||||
detail_response = api_client.get(
|
|
||||||
f"/api/logs/{log_id}/",
|
|
||||||
**_auth_headers(owner),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert detail_response.status_code == 200
|
|
||||||
assert detail_response.data["target"]["name"] == "Filtered Tag"
|
|
||||||
assert 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
|
|
||||||
tag_id = create_response.data["id"]
|
|
||||||
|
|
||||||
delete_response = api_client.delete(
|
|
||||||
f"/api/tags/{tag_id}/",
|
|
||||||
**_auth_headers(owner),
|
|
||||||
)
|
|
||||||
assert delete_response.status_code == 204
|
|
||||||
|
|
||||||
ReportExportJob.objects.create(
|
|
||||||
requesting_user=owner,
|
|
||||||
workspace=workspace,
|
|
||||||
export_type=ReportExportJob.ExportType.PDF,
|
|
||||||
filters={"workspace": str(workspace.id)},
|
|
||||||
status=ReportExportJob.Status.PENDING,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = api_client.get(
|
|
||||||
f"/api/logs/?workspace={workspace.id}&event=delete",
|
|
||||||
**_auth_headers(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 rest_framework.test import APIClient
|
||||||
|
|
||||||
from apps.notifications.services import store as services
|
from apps.notifications.services import store as services
|
||||||
from apps.notifications.services import RedisNotificationStore
|
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.users.models import User
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class WorkspaceMembershipNotificationTests(TestCase):
|
||||||
def fake_redis(monkeypatch):
|
@classmethod
|
||||||
redis = FakeRedis()
|
def setUpTestData(cls):
|
||||||
monkeypatch.setattr(services, "redis_client", redis)
|
cls.owner = cls._create_user(1)
|
||||||
return redis
|
cls.member = cls._create_user(2)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_user(index):
|
||||||
|
return User.objects.create_user(
|
||||||
|
mobile=f"091200000{index:02d}",
|
||||||
|
password="secret123",
|
||||||
|
first_name=f"User{index}",
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.fixture()
|
def setUp(self):
|
||||||
def api_client():
|
self.client = APIClient()
|
||||||
return 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
|
||||||
|
|
||||||
def _create_user(index: int) -> User:
|
@staticmethod
|
||||||
return User.objects.create_user(
|
def _notifications_for(user):
|
||||||
mobile=f"091200000{index:02d}",
|
notifications, _ = RedisNotificationStore.list(str(user.id), paginate=False)
|
||||||
password="secret123",
|
return notifications
|
||||||
first_name=f"User{index}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
def test_workspace_create_notifies_initial_members_not_owner(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
def _notifications_for(user):
|
response = self.client.post(
|
||||||
notifications, _ = RedisNotificationStore.list(
|
"/api/workspaces/",
|
||||||
str(user.id),
|
{
|
||||||
paginate=False,
|
"name": "Ops",
|
||||||
)
|
"description": "Workspace",
|
||||||
return notifications
|
"members": [
|
||||||
|
{
|
||||||
|
"user_id": str(self.member.id),
|
||||||
|
"role": WorkspaceMembership.Role.ADMIN,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.fixture()
|
def test_workspace_membership_crud_emits_all_expected_events(self):
|
||||||
def owner(db):
|
workspace = Workspace.objects.create(name="Design", description="", owner=self.owner)
|
||||||
return _create_user(1)
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/workspace-memberships/",
|
||||||
|
{
|
||||||
|
"workspace": str(workspace.id),
|
||||||
|
"user": str(self.member.id),
|
||||||
|
"role": WorkspaceMembership.Role.MEMBER,
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(create_response.status_code, 201)
|
||||||
|
|
||||||
@pytest.fixture()
|
membership_id = create_response.data["id"]
|
||||||
def member(db):
|
notifications = self._notifications_for(self.member)
|
||||||
return _create_user(2)
|
self.assertEqual(
|
||||||
|
[item["type"] for item in notifications],
|
||||||
|
["workspace_membership_added"],
|
||||||
|
)
|
||||||
|
|
||||||
|
role_response = self.client.patch(
|
||||||
|
f"/api/workspace-memberships/{membership_id}/",
|
||||||
|
{"role": WorkspaceMembership.Role.ADMIN},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(role_response.status_code, 200)
|
||||||
|
|
||||||
@pytest.fixture()
|
deactivate_response = self.client.patch(
|
||||||
def another_member(db):
|
f"/api/workspace-memberships/{membership_id}/",
|
||||||
return _create_user(3)
|
{"is_active": False},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(deactivate_response.status_code, 200)
|
||||||
|
|
||||||
|
remove_response = self.client.delete(
|
||||||
|
f"/api/workspace-memberships/{membership_id}/"
|
||||||
|
)
|
||||||
|
self.assertEqual(remove_response.status_code, 204)
|
||||||
|
|
||||||
@pytest.fixture()
|
notifications = self._notifications_for(self.member)
|
||||||
def third_member(db):
|
self.assertEqual(
|
||||||
return _create_user(4)
|
[item["type"] for item in notifications],
|
||||||
|
[
|
||||||
|
"workspace_membership_removed",
|
||||||
@pytest.fixture()
|
"workspace_membership_deactivated",
|
||||||
def fourth_member(db):
|
"workspace_membership_role_changed",
|
||||||
return _create_user(5)
|
"workspace_membership_added",
|
||||||
|
|
||||||
|
|
||||||
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(
|
|
||||||
"/api/workspaces/",
|
|
||||||
{
|
|
||||||
"name": "Ops",
|
|
||||||
"description": "Workspace",
|
|
||||||
"members": [
|
|
||||||
{"user_id": str(member.id), "role": WorkspaceMembership.Role.ADMIN}
|
|
||||||
],
|
],
|
||||||
},
|
)
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
def test_workspace_membership_update_skips_self_notifications(self):
|
||||||
owner_notifications = _notifications_for(owner)
|
workspace = Workspace.objects.create(name="Product", description="", owner=self.owner)
|
||||||
member_notifications = _notifications_for(member)
|
owner_membership = WorkspaceMembership.objects.get(
|
||||||
|
workspace=workspace,
|
||||||
|
user=self.owner,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
assert owner_notifications == []
|
response = self.client.patch(
|
||||||
assert len(member_notifications) == 1
|
f"/api/workspace-memberships/{owner_membership.id}/",
|
||||||
assert member_notifications[0]["type"] == "workspace_membership_added"
|
{"role": WorkspaceMembership.Role.OWNER},
|
||||||
assert member_notifications[0]["meta"]["workspace_name"] == "Ops"
|
format="json",
|
||||||
assert member_notifications[0]["meta"]["new_role"] == WorkspaceMembership.Role.ADMIN
|
)
|
||||||
|
|
||||||
|
|
||||||
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(
|
|
||||||
"/api/workspace-memberships/",
|
|
||||||
{
|
|
||||||
"workspace": str(workspace.id),
|
|
||||||
"user": str(member.id),
|
|
||||||
"role": WorkspaceMembership.Role.MEMBER,
|
|
||||||
"is_active": True,
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert 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"]
|
|
||||||
|
|
||||||
role_response = api_client.patch(
|
|
||||||
f"/api/workspace-memberships/{membership_id}/",
|
|
||||||
{"role": WorkspaceMembership.Role.ADMIN},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert role_response.status_code == 200
|
|
||||||
|
|
||||||
deactivate_response = api_client.patch(
|
|
||||||
f"/api/workspace-memberships/{membership_id}/",
|
|
||||||
{"is_active": False},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert deactivate_response.status_code == 200
|
|
||||||
|
|
||||||
remove_response = api_client.delete(f"/api/workspace-memberships/{membership_id}/")
|
|
||||||
assert remove_response.status_code == 204
|
|
||||||
|
|
||||||
notifications = _notifications_for(member)
|
|
||||||
assert [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)
|
|
||||||
owner_membership = WorkspaceMembership.objects.get(
|
|
||||||
workspace=workspace,
|
|
||||||
user=owner,
|
|
||||||
is_deleted=False,
|
|
||||||
)
|
|
||||||
api_client.force_authenticate(user=owner)
|
|
||||||
|
|
||||||
response = api_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,200 +1,78 @@
|
|||||||
import json
|
from django.conf import settings
|
||||||
from collections import defaultdict
|
from django.test import TestCase
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from apps.notifications.services import store as services
|
from apps.notifications.services import store as services
|
||||||
from apps.notifications.services import RedisNotificationStore
|
from apps.notifications.services import RedisNotificationStore
|
||||||
|
from apps.notifications.tests.fakes import FakeRedis
|
||||||
|
|
||||||
|
|
||||||
class FakePipeline:
|
class RedisNotificationStoreTests(TestCase):
|
||||||
def __init__(self, client):
|
def setUp(self):
|
||||||
self.client = client
|
self.fake_redis = FakeRedis()
|
||||||
self.operations = []
|
self.original_redis_client = services.redis_client
|
||||||
|
services.redis_client = self.fake_redis
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def tearDown(self):
|
||||||
def wrapper(*args, **kwargs):
|
services.redis_client = self.original_redis_client
|
||||||
self.operations.append((name, args, kwargs))
|
|
||||||
return self
|
|
||||||
|
|
||||||
return wrapper
|
def test_add_publishes_notification_and_unread_count(self):
|
||||||
|
with self.settings(NOTIFICATIONS_ENABLED=True):
|
||||||
|
notification = RedisNotificationStore.add(
|
||||||
|
"user-1",
|
||||||
|
{
|
||||||
|
"title": "Build finished",
|
||||||
|
"message": "Your deploy completed.",
|
||||||
|
"level": "success",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def execute(self):
|
self.assertEqual(notification["title"], "Build finished")
|
||||||
results = []
|
self.assertEqual(notification["message"], "Your deploy completed.")
|
||||||
for name, args, kwargs in self.operations:
|
self.assertEqual(notification["level"], "success")
|
||||||
results.append(getattr(self.client, name)(*args, **kwargs))
|
self.assertEqual(len(self.fake_redis.published), 2)
|
||||||
self.operations.clear()
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
channel, payload = self.fake_redis.published[0]
|
||||||
class FakePubSub:
|
self.assertEqual(
|
||||||
def __init__(self):
|
channel,
|
||||||
self.channels = []
|
f"{settings.NOTIFICATION_REDIS_CHANNEL_PREFIX}:user-1",
|
||||||
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:
|
self.assertEqual(payload["event"], "notification")
|
||||||
return [member for member, _ in items[start:]]
|
self.assertEqual(payload["data"]["notification"]["id"], notification["id"])
|
||||||
return [member for member, _ in items[start : stop + 1]]
|
self.assertEqual(payload["data"]["unread_count"], 1)
|
||||||
|
|
||||||
def hget(self, key, field):
|
def test_mark_seen_and_mark_all_seen_publish_sync_events(self):
|
||||||
return self.hashes[key].get(field)
|
with self.settings(NOTIFICATIONS_ENABLED=True):
|
||||||
|
first = RedisNotificationStore.add("user-2", {"title": "First"})
|
||||||
|
RedisNotificationStore.add("user-2", {"title": "Second"})
|
||||||
|
self.fake_redis.published.clear()
|
||||||
|
|
||||||
def zrem(self, key, *members):
|
payload = RedisNotificationStore.mark_seen("user-2", first["id"])
|
||||||
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):
|
self.assertEqual(payload["notification_id"], first["id"])
|
||||||
removed = 0
|
self.assertFalse(payload["deleted"])
|
||||||
for field in fields:
|
self.assertTrue(payload["notification"]["is_seen"])
|
||||||
if field in self.hashes[key]:
|
self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_seen")
|
||||||
del self.hashes[key][field]
|
|
||||||
removed += 1
|
|
||||||
return removed
|
|
||||||
|
|
||||||
def smembers(self, key):
|
self.fake_redis.published.clear()
|
||||||
return set(self.sets[key])
|
updated = RedisNotificationStore.mark_all_seen("user-2")
|
||||||
|
|
||||||
def srem(self, key, member):
|
self.assertEqual(updated, 2)
|
||||||
if member in self.sets[key]:
|
self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_mark_all_read")
|
||||||
self.sets[key].remove(member)
|
self.assertEqual(self.fake_redis.published[1][1]["event"], "unread_count")
|
||||||
return 1
|
self.assertEqual(self.fake_redis.published[1][1]["data"]["unread_count"], 0)
|
||||||
return 0
|
|
||||||
|
|
||||||
def zrangebyscore(self, key, min_score, max_score):
|
def test_list_returns_total_count_and_filtered_notifications(self):
|
||||||
lower = float("-inf") if min_score == "-inf" else float(min_score)
|
RedisNotificationStore.add("user-3", {"title": "General", "type": "general"})
|
||||||
upper = float(max_score)
|
RedisNotificationStore.add("user-3", {"title": "Billing", "type": "billing"})
|
||||||
return [
|
RedisNotificationStore.add("user-3", {"title": "General 2", "type": "general"})
|
||||||
member
|
|
||||||
for member, score in self.sorted_sets[key].items()
|
|
||||||
if lower <= score <= upper
|
|
||||||
]
|
|
||||||
|
|
||||||
def zcard(self, key):
|
notifications, total_count = RedisNotificationStore.list(
|
||||||
return len(self.sorted_sets[key])
|
"user-3",
|
||||||
|
limit=1,
|
||||||
|
offset=0,
|
||||||
|
type_filter="general",
|
||||||
|
)
|
||||||
|
|
||||||
def publish(self, channel, message):
|
self.assertEqual(total_count, 2)
|
||||||
self.published.append((channel, json.loads(message)))
|
self.assertEqual(len(notifications), 1)
|
||||||
return 1
|
self.assertEqual(notifications[0]["type"], "general")
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
notification = RedisNotificationStore.add(
|
|
||||||
"user-1",
|
|
||||||
{
|
|
||||||
"title": "Build finished",
|
|
||||||
"message": "Your deploy completed.",
|
|
||||||
"level": "success",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_mark_seen_and_mark_all_seen_publish_sync_events(fake_redis, settings):
|
|
||||||
settings.NOTIFICATIONS_ENABLED = True
|
|
||||||
first = RedisNotificationStore.add("user-2", {"title": "First"})
|
|
||||||
second = RedisNotificationStore.add("user-2", {"title": "Second"})
|
|
||||||
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"
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_returns_total_count_and_filtered_notifications(fake_redis):
|
|
||||||
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"})
|
|
||||||
|
|
||||||
notifications, total_count = RedisNotificationStore.list(
|
|
||||||
"user-3",
|
|
||||||
limit=1,
|
|
||||||
offset=0,
|
|
||||||
type_filter="general",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert total_count == 2
|
|
||||||
assert len(notifications) == 1
|
|
||||||
assert 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,166 +1,168 @@
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
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.api import views
|
||||||
from apps.notifications.services import store as services
|
from apps.notifications.services import store as services
|
||||||
from apps.notifications.services import RedisNotificationStore
|
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
|
from apps.users.models import User
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class NotificationViewTests(APITestCase):
|
||||||
def fake_redis(monkeypatch):
|
@classmethod
|
||||||
redis = FakeRedis()
|
def setUpTestData(cls):
|
||||||
monkeypatch.setattr(services, "redis_client", redis)
|
cls.user = User.objects.create_user(mobile="09121111111", password="secret123")
|
||||||
return redis
|
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 tearDown(self):
|
||||||
def user(db):
|
services.redis_client = self.original_redis_client
|
||||||
return User.objects.create_user(mobile="09121111111", password="secret123")
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_sse_chunks(response, count):
|
||||||
|
iterator = iter(response.streaming_content)
|
||||||
|
chunks = []
|
||||||
|
for _ in range(count):
|
||||||
|
chunk = next(iterator)
|
||||||
|
if isinstance(chunk, bytes):
|
||||||
|
chunk = chunk.decode("utf-8")
|
||||||
|
chunks.append(chunk)
|
||||||
|
response.close()
|
||||||
|
return chunks
|
||||||
|
|
||||||
@pytest.fixture()
|
@staticmethod
|
||||||
def second_user(db):
|
def _parse_sse_data(chunk):
|
||||||
return User.objects.create_user(mobile="09122222222", password="secret123")
|
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 _read_sse_chunks(response, count):
|
response = self.client.post("/api/notifications/stream-token/")
|
||||||
iterator = iter(response.streaming_content)
|
|
||||||
chunks = []
|
|
||||||
for _ in range(count):
|
|
||||||
chunk = next(iterator)
|
|
||||||
if isinstance(chunk, bytes):
|
|
||||||
chunk = chunk.decode("utf-8")
|
|
||||||
chunks.append(chunk)
|
|
||||||
response.close()
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.data["token"])
|
||||||
|
self.assertGreater(response.data["expires_in"], 0)
|
||||||
|
|
||||||
def _parse_sse_data(chunk: str) -> dict:
|
def test_stream_endpoint_rejects_missing_and_expired_token(self):
|
||||||
for line in chunk.splitlines():
|
missing = self.client.get("/api/notifications/stream/")
|
||||||
if line.startswith("data: "):
|
self.assertEqual(missing.status_code, 401)
|
||||||
return json.loads(line.removeprefix("data: "))
|
|
||||||
raise AssertionError("SSE payload did not include data")
|
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
def test_stream_token_endpoint_returns_short_lived_token(user):
|
self.assertEqual(expired.status_code, 401)
|
||||||
client = APIClient()
|
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
response = client.post("/api/notifications/stream-token/")
|
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()
|
||||||
|
|
||||||
assert response.status_code == 200
|
with patch.object(
|
||||||
assert response.data["token"]
|
RedisNotificationStore,
|
||||||
assert response.data["expires_in"] > 0
|
"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 = self._read_sse_chunks(response, 2)
|
||||||
|
|
||||||
|
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_rejects_missing_and_expired_token(user, settings):
|
def test_stream_endpoint_emits_heartbeat(self):
|
||||||
client = APIClient()
|
pubsub = FakePubSub()
|
||||||
|
first_now = timezone.now()
|
||||||
|
tick_values = iter(
|
||||||
|
[
|
||||||
|
first_now,
|
||||||
|
first_now,
|
||||||
|
first_now + timedelta(seconds=2),
|
||||||
|
first_now + timedelta(seconds=2),
|
||||||
|
first_now + timedelta(seconds=2),
|
||||||
|
first_now + timedelta(seconds=2),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
last_tick = first_now + timedelta(seconds=2)
|
||||||
|
|
||||||
missing = client.get("/api/notifications/stream/")
|
def fake_now():
|
||||||
assert missing.status_code == 401
|
return next(tick_values, last_tick)
|
||||||
|
|
||||||
settings.NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS = 1
|
with override_settings(NOTIFICATION_SSE_HEARTBEAT_SECONDS=1):
|
||||||
token = views._issue_stream_token_for_user(str(user.id))
|
with patch.object(
|
||||||
time.sleep(1.1)
|
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(self.user.id))
|
||||||
|
chunks = [next(stream) for _ in range(4)]
|
||||||
|
stream.close()
|
||||||
|
|
||||||
expired = client.get(f"/api/notifications/stream/?token={token}")
|
self.assertIn("event: ping", chunks[3])
|
||||||
assert expired.status_code == 401
|
|
||||||
|
|
||||||
|
def test_notification_list_and_seen_endpoints_work(self):
|
||||||
|
notification = RedisNotificationStore.add(
|
||||||
|
str(self.user.id),
|
||||||
|
{"title": "Deploy succeeded", "type": "deploy"},
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
def test_stream_endpoint_sends_only_current_users_notifications(
|
list_response = self.client.get("/api/notifications/list/?type=deploy")
|
||||||
fake_redis, user, second_user, monkeypatch
|
self.assertEqual(list_response.status_code, 200)
|
||||||
):
|
self.assertEqual(list_response.data["count"], 1)
|
||||||
RedisNotificationStore.add(str(user.id), {"title": "For current user"})
|
self.assertEqual(list_response.data["unread_count"], 1)
|
||||||
RedisNotificationStore.add(str(second_user.id), {"title": "For another user"})
|
self.assertEqual(
|
||||||
pubsub = FakePubSub()
|
list_response.data["notifications"][0]["title"],
|
||||||
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub))
|
"Deploy succeeded",
|
||||||
token = views._issue_stream_token_for_user(str(user.id))
|
)
|
||||||
|
|
||||||
client = APIClient()
|
seen_response = self.client.post(
|
||||||
response = client.get(
|
"/api/notifications/seen/",
|
||||||
f"/api/notifications/stream/?token={token}",
|
{"id": notification["id"]},
|
||||||
HTTP_ACCEPT="text/event-stream",
|
format="json",
|
||||||
)
|
)
|
||||||
retry_line, connected_chunk = _read_sse_chunks(response, 2)
|
self.assertEqual(seen_response.status_code, 200)
|
||||||
|
self.assertTrue(seen_response.data["marked_read"])
|
||||||
|
self.assertTrue(seen_response.data["notification"]["is_seen"])
|
||||||
|
|
||||||
assert response.status_code == 200
|
def test_notification_delete_endpoint_removes_notification(self):
|
||||||
assert retry_line.startswith("retry:")
|
notification = RedisNotificationStore.add(
|
||||||
connected = _parse_sse_data(connected_chunk)
|
str(self.user.id),
|
||||||
assert connected["unread_count"] == 1
|
{"title": "Delete me", "type": "deploy"},
|
||||||
assert [item["title"] for item in connected["notifications"]] == ["For current user"]
|
)
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
response = self.client.delete(f"/api/notifications/{notification['id']}/")
|
||||||
|
|
||||||
def test_stream_endpoint_emits_heartbeat(fake_redis, user, settings, monkeypatch):
|
self.assertEqual(response.status_code, 200)
|
||||||
pubsub = FakePubSub()
|
self.assertTrue(response.data["deleted"])
|
||||||
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub))
|
self.assertEqual(response.data["notification_id"], notification["id"])
|
||||||
settings.NOTIFICATION_SSE_HEARTBEAT_SECONDS = 1
|
self.assertIsNone(RedisNotificationStore.get(str(self.user.id), notification["id"]))
|
||||||
|
|
||||||
first_now = timezone.now()
|
|
||||||
tick_values = iter(
|
|
||||||
[
|
|
||||||
first_now,
|
|
||||||
first_now,
|
|
||||||
first_now + timedelta(seconds=2),
|
|
||||||
first_now + timedelta(seconds=2),
|
|
||||||
first_now + timedelta(seconds=2),
|
|
||||||
first_now + timedelta(seconds=2),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
last_tick = first_now + timedelta(seconds=2)
|
|
||||||
|
|
||||||
def fake_now():
|
|
||||||
return next(tick_values, last_tick)
|
|
||||||
|
|
||||||
monkeypatch.setattr(views.timezone, "now", fake_now)
|
|
||||||
view = views.NotificationStreamView()
|
|
||||||
stream = view._build_stream(str(user.id))
|
|
||||||
|
|
||||||
chunks = [next(stream) for _ in range(4)]
|
|
||||||
stream.close()
|
|
||||||
|
|
||||||
assert "event: ping" in chunks[3]
|
|
||||||
|
|
||||||
|
|
||||||
def test_notification_list_and_seen_endpoints_work(fake_redis, user):
|
|
||||||
notification = RedisNotificationStore.add(
|
|
||||||
str(user.id),
|
|
||||||
{"title": "Deploy succeeded", "type": "deploy"},
|
|
||||||
)
|
|
||||||
|
|
||||||
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"},
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
response = client.delete(f"/api/notifications/{notification['id']}/")
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -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")
|
ordering = ("-updated_at", "-created_at")
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["workspace"], name="project_workspace_idx"),
|
models.Index(fields=["workspace"], name="project_workspace_idx"),
|
||||||
|
models.Index(fields=["workspace", "is_archived", "updated_at"], name="project_ws_arch_upd_idx"),
|
||||||
]
|
]
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(
|
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 APITestCase
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
@@ -7,69 +6,65 @@ from apps.users.models import User
|
|||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class ProjectViewTests(APITestCase):
|
||||||
def api_client():
|
@classmethod
|
||||||
return APIClient()
|
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=cls.workspace,
|
||||||
|
user=cls.member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
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()
|
response = self.client.get(
|
||||||
def owner(db):
|
"/api/projects/",
|
||||||
return User.objects.create_user(mobile="09121110001", password="secret123", first_name="Owner")
|
[
|
||||||
|
("workspace", str(self.workspace.id)),
|
||||||
|
("clients", str(self.first_client.id)),
|
||||||
|
("clients", str(self.second_client.id)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
@pytest.fixture()
|
items = (
|
||||||
def workspace(owner):
|
response.data
|
||||||
return Workspace.objects.create(name="Projects", owner=owner)
|
if isinstance(response.data, list)
|
||||||
|
else response.data.get("results") or response.data.get("items", [])
|
||||||
|
)
|
||||||
@pytest.fixture()
|
result_ids = {str(item["client"]["id"]) for item in items}
|
||||||
def member(db, workspace):
|
self.assertEqual(
|
||||||
user = User.objects.create_user(mobile="09121110002", password="secret123", first_name="Member")
|
result_ids,
|
||||||
WorkspaceMembership.objects.create(
|
{str(self.first_client.id), str(self.second_client.id)},
|
||||||
workspace=workspace,
|
)
|
||||||
user=user,
|
|
||||||
role=WorkspaceMembership.Role.MEMBER,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@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(
|
|
||||||
"/api/projects/",
|
|
||||||
[
|
|
||||||
("workspace", str(workspace.id)),
|
|
||||||
("clients", str(first.id)),
|
|
||||||
("clients", str(second.id)),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert 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)}
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ from apps.reports.services import (
|
|||||||
load_report_filters,
|
load_report_filters,
|
||||||
)
|
)
|
||||||
from apps.reports.tasks import generate_report_export_task
|
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):
|
class ReportChartView(APIView):
|
||||||
@@ -30,7 +34,17 @@ class ReportChartView(APIView):
|
|||||||
|
|
||||||
@extend_schema(responses=dict)
|
@extend_schema(responses=dict)
|
||||||
def get(self, request):
|
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):
|
class ReportTableView(APIView):
|
||||||
@@ -38,7 +52,17 @@ class ReportTableView(APIView):
|
|||||||
|
|
||||||
@extend_schema(responses=dict)
|
@extend_schema(responses=dict)
|
||||||
def get(self, request):
|
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):
|
class ReportDayDetailsView(APIView):
|
||||||
@@ -46,7 +70,17 @@ class ReportDayDetailsView(APIView):
|
|||||||
|
|
||||||
@extend_schema(responses=dict)
|
@extend_schema(responses=dict)
|
||||||
def get(self, request):
|
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(
|
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,264 +1,111 @@
|
|||||||
from datetime import timedelta
|
from unittest.mock import patch
|
||||||
from decimal import Decimal
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
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.models import ReportExportJob
|
||||||
from apps.reports.tasks import cleanup_expired_report_exports_task, generate_report_export_task
|
from apps.reports.tasks import (
|
||||||
from apps.time_entries.models import TimeEntry
|
cleanup_expired_report_exports_task,
|
||||||
|
generate_report_export_task,
|
||||||
|
)
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
|
|
||||||
class FakeRedis:
|
class ReportTaskTests(TestCase):
|
||||||
def pipeline(self):
|
@classmethod
|
||||||
return self
|
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)
|
||||||
|
|
||||||
def zadd(self, *args, **kwargs):
|
def test_generate_excel_export_marks_job_complete_and_sends_notification(self):
|
||||||
return self
|
job = ReportExportJob.objects.create(
|
||||||
|
requesting_user=self.owner,
|
||||||
|
workspace=self.workspace,
|
||||||
|
export_type=ReportExportJob.ExportType.EXCEL,
|
||||||
|
filters={
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"from_date": "2026-04-01",
|
||||||
|
"to_date": "2026-04-30",
|
||||||
|
"user": str(self.owner.id),
|
||||||
|
"client": None,
|
||||||
|
"project": None,
|
||||||
|
"tags": [],
|
||||||
|
"language": "en",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def hset(self, *args, **kwargs):
|
with patch("apps.reports.tasks.build_table_report", return_value={"scope": {}, "summary": {}, "days": [], "clients": [], "projects": [], "tags": []}) as build_table_report:
|
||||||
return self
|
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))
|
||||||
|
|
||||||
def sadd(self, *args, **kwargs):
|
job.refresh_from_db()
|
||||||
return self
|
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")
|
||||||
|
|
||||||
def execute(self):
|
def test_generate_pdf_export_failure_marks_job_failed_and_notifies(self):
|
||||||
return []
|
job = ReportExportJob.objects.create(
|
||||||
|
requesting_user=self.owner,
|
||||||
|
workspace=self.workspace,
|
||||||
|
export_type=ReportExportJob.ExportType.PDF,
|
||||||
|
filters={
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"from_date": "2026-04-01",
|
||||||
|
"to_date": "2026-04-30",
|
||||||
|
"user": str(self.owner.id),
|
||||||
|
"client": None,
|
||||||
|
"project": None,
|
||||||
|
"tags": [],
|
||||||
|
"language": "fa",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def publish(self, *args, **kwargs):
|
with patch("apps.reports.tasks.build_table_report", return_value={"scope": {}, "summary": {}, "days": [], "clients": [], "projects": [], "tags": []}):
|
||||||
return None
|
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))
|
||||||
|
|
||||||
def zrevrange(self, *args, **kwargs):
|
job.refresh_from_db()
|
||||||
return []
|
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")
|
||||||
|
|
||||||
def hget(self, *args, **kwargs):
|
def test_cleanup_expires_and_removes_files(self):
|
||||||
return None
|
job = ReportExportJob.objects.create(
|
||||||
|
requesting_user=self.owner,
|
||||||
|
workspace=self.workspace,
|
||||||
|
export_type=ReportExportJob.ExportType.EXCEL,
|
||||||
|
status=ReportExportJob.Status.COMPLETED,
|
||||||
|
filters={},
|
||||||
|
expires_at=timezone.now() - timezone.timedelta(days=1),
|
||||||
|
)
|
||||||
|
file_name = f"reports/exports/{job.id}-old.xlsx"
|
||||||
|
job.file.save(file_name, ContentFile(b"old-data"), save=False)
|
||||||
|
job.save(update_fields=["file", "updated_at"])
|
||||||
|
|
||||||
def zrem(self, *args, **kwargs):
|
removed = cleanup_expired_report_exports_task()
|
||||||
return 1
|
job.refresh_from_db()
|
||||||
|
|
||||||
def hdel(self, *args, **kwargs):
|
self.assertEqual(removed, 1)
|
||||||
return 1
|
self.assertEqual(job.status, ReportExportJob.Status.EXPIRED)
|
||||||
|
self.assertFalse(default_storage.exists(file_name))
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@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):
|
|
||||||
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()
|
|
||||||
|
|
||||||
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,
|
|
||||||
):
|
|
||||||
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,
|
|
||||||
export_type=ReportExportJob.ExportType.PDF,
|
|
||||||
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": "fa",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
generate_report_export_task(str(job.id))
|
|
||||||
job.refresh_from_db()
|
|
||||||
|
|
||||||
assert job.status == ReportExportJob.Status.COMPLETED
|
|
||||||
assert job.file.read(4) == b"%PDF"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_expires_and_removes_files(fake_redis, workspace, owner):
|
|
||||||
job = ReportExportJob.objects.create(
|
|
||||||
requesting_user=owner,
|
|
||||||
workspace=workspace,
|
|
||||||
export_type=ReportExportJob.ExportType.EXCEL,
|
|
||||||
status=ReportExportJob.Status.COMPLETED,
|
|
||||||
filters={},
|
|
||||||
expires_at=timezone.now() - timezone.timedelta(days=1),
|
|
||||||
)
|
|
||||||
file_name = f"reports/exports/{job.id}-old.xlsx"
|
|
||||||
job.file.save(file_name, ContentFile(b"old-data"), save=False)
|
|
||||||
job.save(update_fields=["file", "updated_at"])
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
from django.core.cache import cache
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
@@ -12,194 +13,250 @@ from apps.users.models import User
|
|||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class ReportViewTests(APITestCase):
|
||||||
def api_client():
|
@classmethod
|
||||||
return APIClient()
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
entry_owner = TimeEntry.objects.create(
|
||||||
|
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",
|
||||||
|
duration=timedelta(hours=2),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("25.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
entry_owner.tags.add(cls.tag)
|
||||||
|
|
||||||
@pytest.fixture()
|
entry_member = TimeEntry.objects.create(
|
||||||
def owner(db):
|
workspace=cls.workspace,
|
||||||
return User.objects.create_user(mobile="09128880001", password="secret123", first_name="Owner")
|
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",
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
is_billable=False,
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
entry_member.tags.add(cls.tag)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
@pytest.fixture()
|
def test_member_only_sees_own_chart_report(self):
|
||||||
def admin(db):
|
self.client.force_authenticate(user=self.member)
|
||||||
return User.objects.create_user(mobile="09128880002", password="secret123", first_name="Admin")
|
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/reports/chart/",
|
||||||
|
{"workspace": str(self.workspace.id), "period": "this_month"},
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.fixture()
|
self.assertEqual(response.status_code, 200)
|
||||||
def member(db):
|
self.assertEqual(response.data["summary"]["total_duration"], "01:00:00")
|
||||||
return User.objects.create_user(mobile="09128880003", password="secret123", first_name="Member")
|
|
||||||
|
|
||||||
|
def test_admin_can_request_combined_table_report(self):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
|
||||||
@pytest.fixture()
|
with patch(
|
||||||
def workspace(owner, admin, member):
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
workspace = Workspace.objects.create(name="Reports", owner=owner)
|
return_value=date(2026, 4, 20),
|
||||||
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)
|
response = self.client.get(
|
||||||
return workspace
|
"/api/reports/table/",
|
||||||
|
{"workspace": str(self.workspace.id), "period": "this_month"},
|
||||||
|
)
|
||||||
|
|
||||||
|
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"])
|
||||||
|
|
||||||
@pytest.fixture()
|
def test_daily_rate_uses_latest_billable_entry_snapshot(self):
|
||||||
def client(workspace):
|
self.client.force_authenticate(user=self.owner)
|
||||||
return Client.objects.create(workspace=workspace, name="Acme")
|
|
||||||
|
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
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",
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("20.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
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",
|
||||||
|
duration=timedelta(hours=2),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("35.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.fixture()
|
with patch(
|
||||||
def project(workspace, client):
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
return Project.objects.create(workspace=workspace, name="Website", client=client)
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/reports/table/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"user": str(self.owner.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.fixture()
|
def test_custom_period_longer_than_31_days_is_rejected(self):
|
||||||
def tag(workspace):
|
self.client.force_authenticate(user=self.owner)
|
||||||
return Tag.objects.create(workspace=workspace, name="Design", color="#ffffff")
|
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/reports/chart/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "period",
|
||||||
|
"from_date": "2026-01-01",
|
||||||
|
"to_date": "2026-02-15",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.fixture()
|
self.assertEqual(response.status_code, 400)
|
||||||
def time_entries(workspace, owner, member, project, tag):
|
|
||||||
entry_owner = TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=owner,
|
|
||||||
project=project,
|
|
||||||
description="Owner work",
|
|
||||||
start_time="2026-04-10T08:00:00+03:30",
|
|
||||||
end_time="2026-04-10T10:00:00+03:30",
|
|
||||||
duration=timedelta(hours=2),
|
|
||||||
is_billable=True,
|
|
||||||
hourly_rate=Decimal("25.00"),
|
|
||||||
currency="USD",
|
|
||||||
)
|
|
||||||
entry_owner.tags.add(tag)
|
|
||||||
entry_member = TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=member,
|
|
||||||
project=project,
|
|
||||||
description="Member work",
|
|
||||||
start_time="2026-04-11T09:00:00+03:30",
|
|
||||||
end_time="2026-04-11T10:00:00+03:30",
|
|
||||||
duration=timedelta(hours=1),
|
|
||||||
is_billable=False,
|
|
||||||
currency="USD",
|
|
||||||
)
|
|
||||||
entry_member.tags.add(tag)
|
|
||||||
return [entry_owner, entry_member]
|
|
||||||
|
|
||||||
|
def test_persian_this_month_uses_jalali_month_bounds(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
def test_member_only_sees_own_chart_report(api_client, member, workspace, time_entries):
|
with patch(
|
||||||
api_client.force_authenticate(user=member)
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 27),
|
||||||
|
):
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
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",
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
is_billable=False,
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
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",
|
||||||
|
duration=timedelta(hours=2),
|
||||||
|
is_billable=False,
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
|
||||||
response = api_client.get(
|
response = self.client.get(
|
||||||
"/api/reports/chart/",
|
"/api/reports/table/",
|
||||||
{"workspace": str(workspace.id), "period": "this_month"},
|
{
|
||||||
)
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"language": "fa",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response.data["summary"]["total_duration"] == "01:00:00"
|
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"}
|
||||||
|
|
||||||
def test_admin_can_request_combined_table_report(api_client, admin, workspace, time_entries):
|
with patch(
|
||||||
api_client.force_authenticate(user=admin)
|
"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")
|
||||||
|
|
||||||
response = api_client.get(
|
member_entry = TimeEntry.objects.get(description="Member work")
|
||||||
"/api/reports/table/",
|
TimeEntry.objects.filter(id=member_entry.id).update(duration=timedelta(hours=5))
|
||||||
{"workspace": str(workspace.id), "period": "this_month"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
with patch(
|
||||||
assert response.data["summary"]["total_duration"] == "03:00:00"
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
assert len(response.data["days"]) == 2
|
return_value=date(2026, 4, 20),
|
||||||
assert response.data["days"][0]["latest_hourly_rate"] is None
|
):
|
||||||
assert response.data["days"][1]["latest_hourly_rate"] is None
|
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"])
|
||||||
|
|
||||||
def test_daily_rate_uses_latest_billable_entry_snapshot(api_client, owner, workspace, project):
|
with patch(
|
||||||
api_client.force_authenticate(user=owner)
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
TimeEntry.objects.create(
|
):
|
||||||
workspace=workspace,
|
fresh_response = self.client.get(url, params)
|
||||||
user=owner,
|
self.assertEqual(fresh_response.status_code, 200)
|
||||||
project=project,
|
self.assertEqual(fresh_response.data["summary"]["total_duration"], "07:00:00")
|
||||||
description="Morning work",
|
|
||||||
start_time="2026-04-15T08:00:00+03:30",
|
|
||||||
end_time="2026-04-15T09:00:00+03:30",
|
|
||||||
duration=timedelta(hours=1),
|
|
||||||
is_billable=True,
|
|
||||||
hourly_rate=Decimal("20.00"),
|
|
||||||
currency="USD",
|
|
||||||
)
|
|
||||||
TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=owner,
|
|
||||||
project=project,
|
|
||||||
description="Later work",
|
|
||||||
start_time="2026-04-15T13:00:00+03:30",
|
|
||||||
end_time="2026-04-15T15:00:00+03:30",
|
|
||||||
duration=timedelta(hours=2),
|
|
||||||
is_billable=True,
|
|
||||||
hourly_rate=Decimal("35.00"),
|
|
||||||
currency="USD",
|
|
||||||
)
|
|
||||||
|
|
||||||
response = api_client.get(
|
|
||||||
"/api/reports/table/",
|
|
||||||
{"workspace": str(workspace.id), "period": "this_month", "user": str(owner.id)},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.data["days"][0]["latest_hourly_rate"] == {
|
|
||||||
"amount": "35.00",
|
|
||||||
"currency": "USD",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace):
|
|
||||||
api_client.force_authenticate(user=owner)
|
|
||||||
|
|
||||||
response = api_client.get(
|
|
||||||
"/api/reports/chart/",
|
|
||||||
{
|
|
||||||
"workspace": str(workspace.id),
|
|
||||||
"period": "period",
|
|
||||||
"from_date": "2026-01-01",
|
|
||||||
"to_date": "2026-02-15",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=owner,
|
|
||||||
project=project,
|
|
||||||
description="Previous jalali month",
|
|
||||||
start_time="2026-04-20T08:00:00+03:30",
|
|
||||||
end_time="2026-04-20T09:00:00+03:30",
|
|
||||||
duration=timedelta(hours=1),
|
|
||||||
is_billable=False,
|
|
||||||
currency="USD",
|
|
||||||
)
|
|
||||||
TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=owner,
|
|
||||||
project=project,
|
|
||||||
description="Current jalali month",
|
|
||||||
start_time="2026-04-21T08:00:00+03:30",
|
|
||||||
end_time="2026-04-21T10:00:00+03:30",
|
|
||||||
duration=timedelta(hours=2),
|
|
||||||
is_billable=False,
|
|
||||||
currency="USD",
|
|
||||||
)
|
|
||||||
|
|
||||||
response = api_client.get(
|
|
||||||
"/api/reports/table/",
|
|
||||||
{"workspace": str(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"
|
|
||||||
|
|||||||
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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -56,13 +56,14 @@ class TimeEntry(BaseModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
db_table = "time_entry"
|
db_table = "time_entry"
|
||||||
ordering = ("-updated_at", "-created_at")
|
ordering = ("-updated_at", "-created_at")
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["workspace"], name="time_entry_workspace_idx"),
|
models.Index(fields=["workspace"], name="time_entry_workspace_idx"),
|
||||||
models.Index(fields=["user"], name="time_entry_user_idx"),
|
models.Index(fields=["user"], name="time_entry_user_idx"),
|
||||||
models.Index(fields=["project"], name="time_entry_project_idx"),
|
models.Index(fields=["project"], name="time_entry_project_idx"),
|
||||||
models.Index(fields=["start_time"], name="time_entry_start_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", "start_time"], name="time_entry_workspace_start_idx"),
|
||||||
]
|
models.Index(fields=["workspace", "user", "start_time"], name="time_entry_ws_user_start_idx"),
|
||||||
|
]
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(
|
models.UniqueConstraint(
|
||||||
fields=["workspace", "user"],
|
fields=["workspace", "user"],
|
||||||
|
|||||||
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 datetime import datetime
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
@@ -12,78 +14,94 @@ from apps.workspaces.models import Workspace
|
|||||||
def make_aware(year, month, day, hour=9, minute=0, second=0):
|
def make_aware(year, month, day, hour=9, minute=0, second=0):
|
||||||
from django.utils import timezone
|
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),
|
||||||
def test_time_entry_filter_supports_project_client_tags_and_custom_dates(db):
|
current_timezone,
|
||||||
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")
|
|
||||||
tag_ops = Tag.objects.create(workspace=workspace, name="Ops", color="#10B981")
|
|
||||||
|
|
||||||
entry_a = TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=user,
|
|
||||||
project=project_a,
|
|
||||||
description="Backend work",
|
|
||||||
start_time=make_aware(2026, 4, 10, 10, 0, 0),
|
|
||||||
end_time=make_aware(2026, 4, 10, 12, 0, 0),
|
|
||||||
)
|
|
||||||
entry_a.tags.set([tag_backend])
|
|
||||||
|
|
||||||
entry_b = TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=user,
|
|
||||||
project=project_b,
|
|
||||||
description="Ops work",
|
|
||||||
start_time=make_aware(2026, 4, 18, 14, 0, 0),
|
|
||||||
end_time=make_aware(2026, 4, 18, 15, 30, 0),
|
|
||||||
)
|
|
||||||
entry_b.tags.set([tag_ops])
|
|
||||||
|
|
||||||
queryset = TimeEntry.objects.filter(workspace=workspace, is_deleted=False)
|
|
||||||
|
|
||||||
filtered = TimeEntryFilter(
|
|
||||||
data={
|
|
||||||
"workspace": str(workspace.id),
|
|
||||||
"project": str(project_a.id),
|
|
||||||
"client": str(client_a.id),
|
|
||||||
"tags": str(tag_backend.id),
|
|
||||||
"started_after": "2026-04-01",
|
|
||||||
"started_before": "2026-04-15",
|
|
||||||
},
|
|
||||||
queryset=queryset,
|
|
||||||
).qs
|
|
||||||
|
|
||||||
assert list(filtered) == [entry_a]
|
|
||||||
|
|
||||||
|
|
||||||
def test_time_entry_filter_supports_status_values(db):
|
|
||||||
user = User.objects.create_user(mobile="09125555555", password="secret123")
|
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
|
||||||
|
|
||||||
ended_entry = TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=user,
|
|
||||||
description="Ended entry",
|
|
||||||
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
|
||||||
end_time=make_aware(2026, 4, 24, 10, 0, 0),
|
|
||||||
)
|
|
||||||
running_entry = TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=user,
|
|
||||||
description="Running entry",
|
|
||||||
start_time=make_aware(2026, 4, 15, 9, 0, 0),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
queryset = TimeEntry.objects.filter(workspace=workspace, is_deleted=False)
|
|
||||||
|
|
||||||
ended = TimeEntryFilter(data={"status": "ended"}, queryset=queryset).qs
|
class TimeEntryFilterTests(TestCase):
|
||||||
running = TimeEntryFilter(data={"status": "running"}, queryset=queryset).qs
|
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",
|
||||||
|
)
|
||||||
|
tag_ops = Tag.objects.create(workspace=workspace, name="Ops", color="#10B981")
|
||||||
|
|
||||||
assert list(ended) == [ended_entry]
|
entry_a = TimeEntry.objects.create(
|
||||||
assert list(running) == [running_entry]
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
project=project_a,
|
||||||
|
description="Backend work",
|
||||||
|
start_time=make_aware(2026, 4, 10, 10, 0, 0),
|
||||||
|
end_time=make_aware(2026, 4, 10, 12, 0, 0),
|
||||||
|
)
|
||||||
|
entry_a.tags.set([tag_backend])
|
||||||
|
|
||||||
|
entry_b = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
project=project_b,
|
||||||
|
description="Ops work",
|
||||||
|
start_time=make_aware(2026, 4, 18, 14, 0, 0),
|
||||||
|
end_time=make_aware(2026, 4, 18, 15, 30, 0),
|
||||||
|
)
|
||||||
|
entry_b.tags.set([tag_ops])
|
||||||
|
|
||||||
|
queryset = TimeEntry.objects.filter(workspace=workspace, is_deleted=False)
|
||||||
|
|
||||||
|
filtered = TimeEntryFilter(
|
||||||
|
data={
|
||||||
|
"workspace": str(workspace.id),
|
||||||
|
"project": str(project_a.id),
|
||||||
|
"client": str(client_a.id),
|
||||||
|
"tags": str(tag_backend.id),
|
||||||
|
"started_after": "2026-04-01",
|
||||||
|
"started_before": "2026-04-15",
|
||||||
|
},
|
||||||
|
queryset=queryset,
|
||||||
|
).qs
|
||||||
|
|
||||||
|
self.assertEqual(list(filtered), [entry_a])
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
ended_entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
description="Ended entry",
|
||||||
|
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
||||||
|
end_time=make_aware(2026, 4, 24, 10, 0, 0),
|
||||||
|
)
|
||||||
|
running_entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
description="Running entry",
|
||||||
|
start_time=make_aware(2026, 4, 15, 9, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = TimeEntry.objects.filter(workspace=workspace, is_deleted=False)
|
||||||
|
|
||||||
|
ended = TimeEntryFilter(data={"status": "ended"}, queryset=queryset).qs
|
||||||
|
running = TimeEntryFilter(data={"status": "running"}, queryset=queryset).qs
|
||||||
|
|
||||||
|
self.assertEqual(list(ended), [ended_entry])
|
||||||
|
self.assertEqual(list(running), [running_entry])
|
||||||
|
|||||||
@@ -1,59 +1,66 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
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.projects.models import Project
|
||||||
from apps.tags.models import Tag
|
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.users.models import User
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
|
|
||||||
def test_time_entry_serializer_keeps_seconds(db):
|
class TimeEntrySerializerTests(TestCase):
|
||||||
user = User.objects.create_user(mobile="09123333333", password="secret123")
|
def test_time_entry_serializer_keeps_seconds(self):
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
user = User.objects.create_user(mobile="09123333333", password="secret123")
|
||||||
current_timezone = timezone.get_current_timezone()
|
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)
|
start_time = timezone.make_aware(
|
||||||
end_time = timezone.make_aware(datetime(2026, 4, 23, 11, 0, 5), current_timezone)
|
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(
|
entry = TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
user=user,
|
user=user,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
data = TimeEntrySerializer(entry).data
|
data = TimeEntrySerializer(entry).data
|
||||||
|
|
||||||
assert data["start_time"] == start_time.strftime("%Y-%m-%d %H:%M:%S")
|
self.assertEqual(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["end_time"], end_time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
|
||||||
|
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")
|
||||||
|
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#334155")
|
||||||
|
project.delete()
|
||||||
|
tag.delete()
|
||||||
|
|
||||||
def test_time_entry_serializer_includes_deleted_project_and_tags(db):
|
entry = TimeEntry.objects.create(
|
||||||
user = User.objects.create_user(mobile="09124444444", password="secret123")
|
workspace=workspace,
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
user=user,
|
||||||
project = Project.objects.create(workspace=workspace, name="Legacy Project")
|
project=Project.all_objects.get(id=project.id),
|
||||||
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#334155")
|
description="Historical work",
|
||||||
project.delete()
|
start_time=timezone.now(),
|
||||||
tag.delete()
|
end_time=timezone.now(),
|
||||||
|
)
|
||||||
|
entry.tags.set([Tag.all_objects.get(id=tag.id)])
|
||||||
|
|
||||||
entry = TimeEntry.objects.create(
|
data = TimeEntrySerializer(entry).data
|
||||||
workspace=workspace,
|
|
||||||
user=user,
|
|
||||||
project=Project.all_objects.get(id=project.id),
|
|
||||||
description="Historical work",
|
|
||||||
start_time=timezone.now(),
|
|
||||||
end_time=timezone.now(),
|
|
||||||
)
|
|
||||||
entry.tags.set([Tag.all_objects.get(id=tag.id)])
|
|
||||||
|
|
||||||
data = TimeEntrySerializer(entry).data
|
self.assertEqual(data["project"], str(project.id))
|
||||||
|
self.assertEqual(data["project_details"]["name"], "Legacy Project")
|
||||||
assert data["project"] == str(project.id)
|
self.assertTrue(data["project_details"]["is_deleted"])
|
||||||
assert data["project_details"]["name"] == "Legacy Project"
|
self.assertEqual(data["tags"], [str(tag.id)])
|
||||||
assert data["project_details"]["is_deleted"] is True
|
self.assertEqual(data["tag_details"][0]["name"], "Legacy Tag")
|
||||||
assert data["tags"] == [str(tag.id)]
|
self.assertTrue(data["tag_details"][0]["is_deleted"])
|
||||||
assert data["tag_details"][0]["name"] == "Legacy Tag"
|
|
||||||
assert data["tag_details"][0]["is_deleted"] is True
|
|
||||||
|
|||||||
@@ -1,78 +1,87 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
from apps.tags.models import Tag
|
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.users.models import User
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
class TimeEntryServiceTests(TestCase):
|
||||||
def workspace_owner(db):
|
@classmethod
|
||||||
user = User.objects.create_user(mobile="09121111111", password="secret123")
|
def setUpTestData(cls):
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
cls.user = User.objects.create_user(mobile="09121111111", password="secret123")
|
||||||
return user, workspace
|
cls.workspace = Workspace.objects.create(name="Core", owner=cls.user)
|
||||||
|
|
||||||
|
def test_create_time_entry_allows_only_one_running_timer_per_workspace(self):
|
||||||
def test_create_time_entry_allows_only_one_running_timer_per_workspace(workspace_owner):
|
|
||||||
user, workspace = workspace_owner
|
|
||||||
|
|
||||||
create_time_entry(
|
|
||||||
user=user,
|
|
||||||
workspace_id=workspace.id,
|
|
||||||
start_time=timezone.now(),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
create_time_entry(
|
create_time_entry(
|
||||||
user=user,
|
user=self.user,
|
||||||
workspace_id=workspace.id,
|
workspace_id=self.workspace.id,
|
||||||
start_time=timezone.now() + timedelta(minutes=5),
|
start_time=timezone.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
create_time_entry(
|
||||||
|
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):
|
def test_stop_time_entry_sets_end_time_and_duration(self):
|
||||||
user, workspace = workspace_owner
|
entry = create_time_entry(
|
||||||
entry = create_time_entry(
|
user=self.user,
|
||||||
user=user,
|
workspace_id=self.workspace.id,
|
||||||
workspace_id=workspace.id,
|
start_time=timezone.now() - timedelta(hours=1),
|
||||||
start_time=timezone.now() - timedelta(hours=1),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
stopped_entry = stop_time_entry(entry, end_time=timezone.now())
|
stopped_entry = stop_time_entry(entry, end_time=timezone.now())
|
||||||
|
|
||||||
assert stopped_entry.end_time is not None
|
self.assertIsNotNone(stopped_entry.end_time)
|
||||||
assert stopped_entry.duration is not None
|
self.assertIsNotNone(stopped_entry.duration)
|
||||||
|
|
||||||
|
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=self.user,
|
||||||
|
workspace_id=self.workspace.id,
|
||||||
|
start_time=timezone.now() - timedelta(hours=1),
|
||||||
|
end_time=timezone.now(),
|
||||||
|
project=project,
|
||||||
|
tags=[tag],
|
||||||
|
description="Before delete",
|
||||||
|
)
|
||||||
|
|
||||||
def test_update_time_entry_preserves_deleted_project_and_tags(workspace_owner):
|
project.delete()
|
||||||
user, workspace = workspace_owner
|
tag.delete()
|
||||||
project = Project.objects.create(workspace=workspace, name="Deleted project")
|
|
||||||
tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#0f172a")
|
|
||||||
entry = create_time_entry(
|
|
||||||
user=user,
|
|
||||||
workspace_id=workspace.id,
|
|
||||||
start_time=timezone.now() - timedelta(hours=1),
|
|
||||||
end_time=timezone.now(),
|
|
||||||
project=project,
|
|
||||||
tags=[tag],
|
|
||||||
description="Before delete",
|
|
||||||
)
|
|
||||||
|
|
||||||
project.delete()
|
updated_entry = update_time_entry(
|
||||||
tag.delete()
|
entry,
|
||||||
|
project=Project.all_objects.get(id=project.id),
|
||||||
|
tags=[Tag.all_objects.get(id=tag.id)],
|
||||||
|
description="After delete",
|
||||||
|
)
|
||||||
|
|
||||||
updated_entry = update_time_entry(
|
self.assertEqual(updated_entry.description, "After delete")
|
||||||
entry,
|
self.assertEqual(updated_entry.project_id, project.id)
|
||||||
project=Project.all_objects.get(id=project.id),
|
self.assertEqual(
|
||||||
tags=[Tag.all_objects.get(id=tag.id)],
|
list(
|
||||||
description="After delete",
|
Tag.all_objects.filter(time_entries=updated_entry).values_list(
|
||||||
)
|
"id",
|
||||||
|
flat=True,
|
||||||
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]
|
[tag.id],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from django.utils import timezone
|
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.tags.models import Tag
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
@@ -10,131 +10,132 @@ from apps.workspaces.models import Workspace
|
|||||||
|
|
||||||
|
|
||||||
def make_aware(year, month, day, hour=9, minute=0, second=0):
|
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),
|
||||||
def test_time_entry_list_returns_grouped_payload_for_ended_entries(db):
|
current_timezone,
|
||||||
user = User.objects.create_user(mobile="09126666666", password="secret123")
|
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
|
||||||
|
|
||||||
first_entry = TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=user,
|
|
||||||
description="Morning work",
|
|
||||||
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
|
||||||
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
|
||||||
)
|
|
||||||
TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=user,
|
|
||||||
description="Running work",
|
|
||||||
start_time=make_aware(2026, 4, 24, 11, 0, 0),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
response = client.get(
|
class TimeEntryViewTests(APITestCase):
|
||||||
"/api/time-entries/",
|
def test_time_entry_list_returns_grouped_payload_for_ended_entries(self):
|
||||||
{
|
user = User.objects.create_user(mobile="09126666666", password="secret123")
|
||||||
"workspace": str(workspace.id),
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
"status": "ended",
|
|
||||||
"limit": 10,
|
|
||||||
"offset": 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
first_entry = TimeEntry.objects.create(
|
||||||
assert response.data["current_page_items_count"] == 1
|
workspace=workspace,
|
||||||
assert response.data["has_more"] is False
|
user=user,
|
||||||
assert len(response.data["groups"]) == 1
|
description="Morning work",
|
||||||
assert len(response.data["groups"][0]["days"]) == 1
|
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
||||||
assert response.data["groups"][0]["days"][0]["entries"][0]["id"] == str(first_entry.id)
|
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
||||||
|
)
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
description="Running work",
|
||||||
|
start_time=make_aware(2026, 4, 24, 11, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=user)
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/time-entries/",
|
||||||
|
{
|
||||||
|
"workspace": str(workspace.id),
|
||||||
|
"status": "ended",
|
||||||
|
"limit": 10,
|
||||||
|
"offset": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_time_entry_update_preserves_current_deleted_tags(db):
|
self.assertEqual(response.status_code, 200)
|
||||||
user = User.objects.create_user(mobile="09127777777", password="secret123")
|
self.assertEqual(response.data["current_page_items_count"], 1)
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
self.assertFalse(response.data["has_more"])
|
||||||
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#475569")
|
self.assertEqual(len(response.data["groups"]), 1)
|
||||||
entry = TimeEntry.objects.create(
|
self.assertEqual(len(response.data["groups"][0]["days"]), 1)
|
||||||
workspace=workspace,
|
self.assertEqual(
|
||||||
user=user,
|
response.data["groups"][0]["days"][0]["entries"][0]["id"],
|
||||||
description="Old",
|
str(first_entry.id),
|
||||||
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
)
|
||||||
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
|
||||||
)
|
|
||||||
entry.tags.set([tag])
|
|
||||||
tag.delete()
|
|
||||||
|
|
||||||
client = APIClient()
|
def test_time_entry_update_preserves_current_deleted_tags(self):
|
||||||
client.force_authenticate(user=user)
|
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")
|
||||||
|
entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
description="Old",
|
||||||
|
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
||||||
|
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
||||||
|
)
|
||||||
|
entry.tags.set([tag])
|
||||||
|
tag.delete()
|
||||||
|
|
||||||
response = client.patch(
|
self.client.force_authenticate(user=user)
|
||||||
f"/api/time-entries/{entry.id}/",
|
response = self.client.patch(
|
||||||
{
|
f"/api/time-entries/{entry.id}/",
|
||||||
"description": "Still editable",
|
{
|
||||||
"tags": [str(tag.id)],
|
"description": "Still editable",
|
||||||
},
|
"tags": [str(tag.id)],
|
||||||
format="json",
|
},
|
||||||
)
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response.data["description"] == "Still editable"
|
self.assertEqual(response.data["description"], "Still editable")
|
||||||
assert response.data["tag_details"][0]["is_deleted"] is True
|
self.assertTrue(response.data["tag_details"][0]["is_deleted"])
|
||||||
|
|
||||||
|
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.delete()
|
||||||
|
entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
description="Entry",
|
||||||
|
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
||||||
|
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
||||||
|
)
|
||||||
|
|
||||||
def test_time_entry_update_rejects_new_deleted_tag_attachment(db):
|
self.client.force_authenticate(user=user)
|
||||||
user = User.objects.create_user(mobile="09128888888", password="secret123")
|
response = self.client.patch(
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
f"/api/time-entries/{entry.id}/",
|
||||||
deleted_tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#475569")
|
{"tags": [str(deleted_tag.id)]},
|
||||||
deleted_tag.delete()
|
format="json",
|
||||||
entry = TimeEntry.objects.create(
|
)
|
||||||
workspace=workspace,
|
|
||||||
user=user,
|
|
||||||
description="Entry",
|
|
||||||
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
|
||||||
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
self.assertEqual(response.status_code, 400)
|
||||||
client.force_authenticate(user=user)
|
self.assertIn("unavailable", response.data["error"].lower())
|
||||||
|
|
||||||
response = client.patch(
|
def test_time_entry_update_can_remove_current_deleted_tag(self):
|
||||||
f"/api/time-entries/{entry.id}/",
|
user = User.objects.create_user(mobile="09129999999", password="secret123")
|
||||||
{
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
"tags": [str(deleted_tag.id)],
|
deleted_tag = Tag.objects.create(
|
||||||
},
|
workspace=workspace,
|
||||||
format="json",
|
name="Deleted tag",
|
||||||
)
|
color="#475569",
|
||||||
|
)
|
||||||
|
entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
description="Entry",
|
||||||
|
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
||||||
|
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
||||||
|
)
|
||||||
|
entry.tags.set([deleted_tag])
|
||||||
|
deleted_tag.delete()
|
||||||
|
|
||||||
assert response.status_code == 400
|
self.client.force_authenticate(user=user)
|
||||||
assert "unavailable" in response.data["error"].lower()
|
response = self.client.patch(
|
||||||
|
f"/api/time-entries/{entry.id}/",
|
||||||
|
{"tags": []},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
def test_time_entry_update_can_remove_current_deleted_tag(db):
|
self.assertEqual(response.data["tags"], [])
|
||||||
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")
|
|
||||||
entry = TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=user,
|
|
||||||
description="Entry",
|
|
||||||
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
|
||||||
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
|
||||||
)
|
|
||||||
entry.tags.set([deleted_tag])
|
|
||||||
deleted_tag.delete()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/time-entries/{entry.id}/",
|
|
||||||
{
|
|
||||||
"tags": [],
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.data["tags"] == []
|
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
import logging
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
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 drf_spectacular.utils import extend_schema_serializer
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from core.serializers.base import BaseModelSerializer
|
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()
|
User = get_user_model()
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfilePictureSerializer(BaseModelSerializer):
|
class UserProfilePictureSerializer(BaseModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -51,10 +39,10 @@ class RegisterSerializer(serializers.Serializer):
|
|||||||
re_password = data.get("re_password", "")
|
re_password = data.get("re_password", "")
|
||||||
|
|
||||||
if not (mobile.isdigit() and len(mobile) == 11):
|
if not (mobile.isdigit() and len(mobile) == 11):
|
||||||
raise serializers.ValidationError({"mobile": "فرمت شماره موبایل نادرست است."})
|
raise serializers.ValidationError({"mobile": "ÙØ±Ù…ت شماره موبایل نادرست است."})
|
||||||
|
|
||||||
if password != re_password:
|
if password != re_password:
|
||||||
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
|
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -65,11 +53,8 @@ class SendOTPSerializer(serializers.Serializer):
|
|||||||
mode = serializers.ChoiceField(choices=["register", "login", "forget_password"])
|
mode = serializers.ChoiceField(choices=["register", "login", "forget_password"])
|
||||||
|
|
||||||
def validate_mobile(self, value):
|
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"):
|
if not value.isdigit() or len(value) != 11 or not value.startswith("09"):
|
||||||
raise serializers.ValidationError("شماره موبایل معتبر نیست.")
|
raise serializers.ValidationError("شماره موبایل معتبر نیست.")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -80,7 +65,7 @@ class LoginOtpSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
def validate_mobile(self, value):
|
def validate_mobile(self, value):
|
||||||
if not (value.isdigit() and len(value) == 11):
|
if not (value.isdigit() and len(value) == 11):
|
||||||
raise serializers.ValidationError("فرمت شماره موبایل نادرست است.")
|
raise serializers.ValidationError("ÙØ±Ù…ت شماره موبایل نادرست است.")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -90,10 +75,30 @@ class LoginSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
def validate_mobile(self, value):
|
def validate_mobile(self, value):
|
||||||
if not (value.isdigit() and len(value) == 11):
|
if not (value.isdigit() and len(value) == 11):
|
||||||
raise serializers.ValidationError("فرمت شماره موبایل نادرست است.")
|
raise serializers.ValidationError("ÙØ±Ù…ت شماره موبایل نادرست است.")
|
||||||
return value
|
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):
|
class ResetPasswordSerializer(serializers.Serializer):
|
||||||
mobile = serializers.CharField(max_length=11)
|
mobile = serializers.CharField(max_length=11)
|
||||||
code = serializers.CharField(max_length=6)
|
code = serializers.CharField(max_length=6)
|
||||||
@@ -102,7 +107,7 @@ class ResetPasswordSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
if data.get("password") != data.get("re_password"):
|
if data.get("password") != data.get("re_password"):
|
||||||
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
|
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -113,7 +118,7 @@ class ChangePasswordSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
if data.get("new_password") != data.get("re_password"):
|
if data.get("new_password") != data.get("re_password"):
|
||||||
raise serializers.ValidationError({"new_password": "رمز عبور جدید و تکرار آن مطابقت ندارند."})
|
raise serializers.ValidationError({"new_password": "رمز عبور جدید و تکرار آن مطابقت ندارند."})
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -138,9 +143,16 @@ class UserProfileSerializer(BaseModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = BaseModelSerializer.Meta.fields + (
|
fields = BaseModelSerializer.Meta.fields + (
|
||||||
"mobile", "email", "first_name", "last_name",
|
"mobile",
|
||||||
"description", "profile_picture", "birth_date",
|
"email",
|
||||||
"is_verified", "full_name", "age"
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"description",
|
||||||
|
"profile_picture",
|
||||||
|
"birth_date",
|
||||||
|
"is_verified",
|
||||||
|
"full_name",
|
||||||
|
"age",
|
||||||
)
|
)
|
||||||
read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified")
|
read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified")
|
||||||
|
|
||||||
@@ -149,9 +161,9 @@ class UserSearchSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = (
|
fields = (
|
||||||
'id',
|
"id",
|
||||||
'first_name',
|
"first_name",
|
||||||
'last_name',
|
"last_name",
|
||||||
'mobile',
|
"mobile",
|
||||||
'profile_picture',
|
"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 = [
|
urlpatterns = [
|
||||||
path("register/", views.RegisterWithOTPView.as_view(), name="register_verify"),
|
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/send/", views.SendOTPView.as_view(), name="send_otp"),
|
||||||
path("otp/login/", views.LoginOTPView.as_view(), name="login_otp"),
|
path("otp/login/", views.LoginOTPView.as_view(), name="login_otp"),
|
||||||
path("login/", views.LoginView.as_view(), name="login"),
|
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("logout/", views.LogoutView.as_view(), name="logout"),
|
||||||
path("password/set/", views.SetPasswordView.as_view(), name="set_password"),
|
path("password/set/", views.SetPasswordView.as_view(), name="set_password"),
|
||||||
path("password/reset/", views.ResetPasswordView.as_view(), name="reset_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.contrib.auth import get_user_model
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
@@ -19,6 +20,9 @@ from apps.users.api.serializers import (
|
|||||||
ChangePasswordSerializer,
|
ChangePasswordSerializer,
|
||||||
LoginOtpSerializer,
|
LoginOtpSerializer,
|
||||||
LoginSerializer,
|
LoginSerializer,
|
||||||
|
GoogleOAuthClaimVerifySerializer,
|
||||||
|
GoogleOAuthCompleteSerializer,
|
||||||
|
GoogleOAuthFlowSerializer,
|
||||||
RegisterSerializer,
|
RegisterSerializer,
|
||||||
ResetPasswordSerializer,
|
ResetPasswordSerializer,
|
||||||
SendOTPSerializer,
|
SendOTPSerializer,
|
||||||
@@ -30,6 +34,15 @@ from apps.users.api.serializers import (
|
|||||||
UserProfileSerializer,
|
UserProfileSerializer,
|
||||||
UserSearchSerializer,
|
UserSearchSerializer,
|
||||||
)
|
)
|
||||||
|
from apps.users.api.throttles import (
|
||||||
|
OTPLoginThrottle,
|
||||||
|
OTPSendBurstThrottle,
|
||||||
|
OTPSendSustainedThrottle,
|
||||||
|
PasswordLoginThrottle,
|
||||||
|
GoogleClaimSendBurstThrottle,
|
||||||
|
GoogleClaimSendSustainedThrottle,
|
||||||
|
GoogleClaimVerifyThrottle,
|
||||||
|
)
|
||||||
from apps.users.services.auth import (
|
from apps.users.services.auth import (
|
||||||
register_user_with_password,
|
register_user_with_password,
|
||||||
register_user_with_otp,
|
register_user_with_otp,
|
||||||
@@ -40,6 +53,20 @@ from apps.users.services.auth import (
|
|||||||
change_password,
|
change_password,
|
||||||
logout_user
|
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()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -91,6 +118,7 @@ class SendOTPView(APIView):
|
|||||||
+ password reset
|
+ password reset
|
||||||
"""
|
"""
|
||||||
permission_classes = (AllowAny,)
|
permission_classes = (AllowAny,)
|
||||||
|
throttle_classes = [OTPSendBurstThrottle, OTPSendSustainedThrottle]
|
||||||
|
|
||||||
@extend_schema(request=SendOTPSerializer, responses=None)
|
@extend_schema(request=SendOTPSerializer, responses=None)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
@@ -107,6 +135,7 @@ class SendOTPView(APIView):
|
|||||||
|
|
||||||
class LoginView(APIView):
|
class LoginView(APIView):
|
||||||
permission_classes = (AllowAny,)
|
permission_classes = (AllowAny,)
|
||||||
|
throttle_classes = [PasswordLoginThrottle]
|
||||||
|
|
||||||
@extend_schema(request=LoginSerializer, responses=TokenPairSerializer)
|
@extend_schema(request=LoginSerializer, responses=TokenPairSerializer)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
@@ -123,6 +152,7 @@ class LoginView(APIView):
|
|||||||
|
|
||||||
class LoginOTPView(APIView):
|
class LoginOTPView(APIView):
|
||||||
permission_classes = (AllowAny,)
|
permission_classes = (AllowAny,)
|
||||||
|
throttle_classes = [OTPLoginThrottle]
|
||||||
|
|
||||||
@extend_schema(request=LoginOtpSerializer, responses=TokenPairSerializer)
|
@extend_schema(request=LoginOtpSerializer, responses=TokenPairSerializer)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
@@ -137,6 +167,88 @@ class LoginOTPView(APIView):
|
|||||||
return Response(tokens, status=status.HTTP_200_OK)
|
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):
|
class ResetPasswordView(APIView):
|
||||||
permission_classes = (AllowAny,)
|
permission_classes = (AllowAny,)
|
||||||
serializer_class = ResetPasswordSerializer
|
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):
|
def __str__(self):
|
||||||
return f"LoginAttempt for User: {self.user} ({'✅' if self.status else '❌'})"
|
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):
|
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"
|
SMS_ENDPOINT = "https://api.sms.ir/v1/send/verify"
|
||||||
|
|
||||||
variables = variables or []
|
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 = {
|
payload = {
|
||||||
"mobile": receptor,
|
"mobile": receptor,
|
||||||
"templateId": str(pattern_code),
|
"templateId": int(pattern_code),
|
||||||
"parameters": variables,
|
"parameters": variables,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"Sending SMS to {receptor} with payload: {payload}")
|
logger.info("Sending SMS to %s with payload: %s", receptor, payload)
|
||||||
|
|
||||||
try:
|
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("Response status: %s", response.status_code)
|
||||||
logger.info(f"Response text: {response.text}")
|
logger.info("Response text: %s", response.text)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
result = response.json()
|
result = response.json()
|
||||||
|
|
||||||
if str(result.get("status", "")) == "1":
|
if str(result.get("status", "")) == "1":
|
||||||
logger.info(f"SMS sent successfully to {receptor}")
|
logger.info("SMS sent successfully to %s", receptor)
|
||||||
else:
|
else:
|
||||||
logger.error(f"SMS.ir API error: {result}")
|
logger.error("SMS.ir API error: %s", result)
|
||||||
else:
|
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
|
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 import status
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
|
|
||||||
|
|
||||||
def test_profile_picture_delete_returns_profile_payload(db):
|
class ProfilePictureApiTests(APITestCase):
|
||||||
user = User.objects.create_user(mobile="09120000000", password="secret123")
|
def test_profile_picture_delete_returns_profile_payload(self):
|
||||||
client = APIClient()
|
user = User.objects.create_user(mobile="09120000000", password="secret123")
|
||||||
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
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
assert response.data["profile_picture"] is None
|
self.assertIsNone(response.data["profile_picture"])
|
||||||
|
|
||||||
user.refresh_from_db()
|
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):
|
class UserTaskTests(TestCase):
|
||||||
settings.SMS_APIKEY = ""
|
def test_send_verification_sms_skips_real_delivery_without_api_key(self):
|
||||||
|
with self.settings(SMS_APIKEY=""):
|
||||||
|
result = send_verification_sms("09123456789", "12345")
|
||||||
|
|
||||||
result = send_verification_sms("09123456789", "12345")
|
self.assertEqual(
|
||||||
|
result,
|
||||||
|
{
|
||||||
|
"mobile": "09123456789",
|
||||||
|
"code": "12345",
|
||||||
|
"sent": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
assert result == {
|
@patch("apps.users.tasks._send_sms")
|
||||||
"mobile": "09123456789",
|
def test_send_verification_sms_calls_sender_when_api_key_exists(self, send_sms):
|
||||||
"code": "12345",
|
send_sms.return_value = Mock(status_code=200)
|
||||||
"sent": False,
|
|
||||||
}
|
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,
|
update_workspace_user_rate,
|
||||||
)
|
)
|
||||||
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
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):
|
class WorkspaceViewSet(ModelViewSet):
|
||||||
@@ -129,7 +140,15 @@ class WorkspaceMembershipViewSet(ModelViewSet):
|
|||||||
status=status.HTTP_403_FORBIDDEN,
|
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):
|
def create(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
@@ -271,6 +290,16 @@ class PriceUnitViewSet(ModelViewSet):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return PriceUnit.objects.filter(is_deleted=False)
|
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):
|
class WorkspaceUserRateViewSet(ModelViewSet):
|
||||||
serializer_class = WorkspaceUserRateSerializer
|
serializer_class = WorkspaceUserRateSerializer
|
||||||
@@ -310,7 +339,18 @@ class WorkspaceUserRateViewSet(ModelViewSet):
|
|||||||
)
|
)
|
||||||
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
|
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
|
||||||
self._ensure_manage_access(request.user, workspace)
|
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):
|
def create(self, request, *args, **kwargs):
|
||||||
serializer = self.get_serializer(data=request.data)
|
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 = [
|
indexes = [
|
||||||
models.Index(fields=["workspace"], name="membership_workspace_idx"),
|
models.Index(fields=["workspace"], name="membership_workspace_idx"),
|
||||||
models.Index(fields=["user"], name="membership_user_idx"),
|
models.Index(fields=["user"], name="membership_user_idx"),
|
||||||
|
models.Index(fields=["workspace", "is_active", "user"], name="membership_ws_active_user_idx"),
|
||||||
]
|
]
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(
|
models.UniqueConstraint(
|
||||||
|
|||||||
@@ -1,14 +1,88 @@
|
|||||||
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 django.dispatch import receiver
|
||||||
|
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
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)
|
@receiver(post_save, sender=Workspace)
|
||||||
def create_owner_membership(sender, instance, created, **kwargs):
|
def create_owner_membership(sender, instance, created, **kwargs):
|
||||||
if created:
|
if created:
|
||||||
WorkspaceMembership.objects.create(
|
WorkspaceMembership.objects.create(
|
||||||
workspace=instance,
|
workspace=instance,
|
||||||
user=instance.owner,
|
user=instance.owner,
|
||||||
role=WorkspaceMembership.Role.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
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.utils import timezone
|
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.clients.models import Client
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
@@ -11,326 +10,378 @@ from apps.users.models import User
|
|||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class WorkspaceCapabilityTests(APITestCase):
|
||||||
def api_client():
|
@classmethod
|
||||||
return APIClient()
|
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
|
||||||
return User.objects.create_user(
|
def _user(index):
|
||||||
mobile=f"091255500{index:02d}",
|
return User.objects.create_user(
|
||||||
password="secret123",
|
mobile=f"091255500{index:02d}",
|
||||||
first_name=f"User{index}",
|
password="secret123",
|
||||||
)
|
first_name=f"User{index}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_member_is_read_only_for_clients_and_projects(self):
|
||||||
|
client = Client.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
name="Existing Client",
|
||||||
|
notes="",
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=self.member)
|
||||||
|
|
||||||
@pytest.fixture()
|
client_response = self.client.post(
|
||||||
def owner(db):
|
"/api/clients/",
|
||||||
return _user(1)
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": "Acme",
|
||||||
|
"notes": "",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
update_client_response = self.client.patch(
|
||||||
|
f"/api/clients/{client.id}/",
|
||||||
|
{"name": "Updated"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
delete_client_response = self.client.delete(f"/api/clients/{client.id}/")
|
||||||
|
project_response = self.client.post(
|
||||||
|
"/api/projects/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"name": "Beta",
|
||||||
|
"description": "",
|
||||||
|
"client": None,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
update_project_response = self.client.patch(
|
||||||
|
f"/api/projects/{self.project.id}/",
|
||||||
|
{"description": "Blocked edit"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
@pytest.fixture()
|
def test_member_can_create_tags_and_manage_own_time_entries(self):
|
||||||
def admin(db):
|
tag = Tag.objects.create(
|
||||||
return _user(2)
|
workspace=self.workspace,
|
||||||
|
name="Existing",
|
||||||
|
color="#000000",
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=self.member)
|
||||||
|
|
||||||
|
create_tag_response = self.client.post(
|
||||||
|
"/api/tags/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": "New Tag",
|
||||||
|
"color": "#ffffff",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
update_tag_response = self.client.patch(
|
||||||
|
f"/api/tags/{tag.id}/",
|
||||||
|
{"name": "Changed"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
delete_tag_response = self.client.delete(f"/api/tags/{tag.id}/")
|
||||||
|
|
||||||
@pytest.fixture()
|
now = timezone.now()
|
||||||
def member(db):
|
create_entry_response = self.client.post(
|
||||||
return _user(3)
|
"/api/time-entries/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"start_time": now.isoformat(),
|
||||||
|
"end_time": (now + timedelta(hours=1)).isoformat(),
|
||||||
|
"description": "Focus block",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
@pytest.fixture()
|
entry_id = create_entry_response.data["id"]
|
||||||
def guest(db):
|
update_entry_response = self.client.patch(
|
||||||
return _user(4)
|
f"/api/time-entries/{entry_id}/",
|
||||||
|
{"description": "Updated focus block"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
delete_entry_response = self.client.delete(f"/api/time-entries/{entry_id}/")
|
||||||
|
|
||||||
|
self.assertEqual(update_entry_response.status_code, 200)
|
||||||
|
self.assertEqual(delete_entry_response.status_code, 204)
|
||||||
|
|
||||||
@pytest.fixture()
|
def test_guest_is_read_only_for_workspace_resources(self):
|
||||||
def extra_owner(db):
|
Client.objects.create(workspace=self.workspace, name="Visible Client", notes="")
|
||||||
return _user(5)
|
Tag.objects.create(workspace=self.workspace, name="Visible Tag", color="#123456")
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=self.guest)
|
||||||
|
|
||||||
@pytest.fixture()
|
list_clients_response = self.client.get(
|
||||||
def workspace(owner, admin, member, guest):
|
f"/api/clients/?workspace={self.workspace.id}"
|
||||||
workspace = Workspace.objects.create(name="Ops", description="", owner=owner)
|
)
|
||||||
WorkspaceMembership.objects.create(
|
list_projects_response = self.client.get(
|
||||||
workspace=workspace,
|
f"/api/projects/?workspace={self.workspace.id}"
|
||||||
user=admin,
|
)
|
||||||
role=WorkspaceMembership.Role.ADMIN,
|
create_tag_response = self.client.post(
|
||||||
is_active=True,
|
"/api/tags/",
|
||||||
)
|
{
|
||||||
WorkspaceMembership.objects.create(
|
"workspace_id": str(self.workspace.id),
|
||||||
workspace=workspace,
|
"name": "Blocked",
|
||||||
user=member,
|
"color": "#ffffff",
|
||||||
role=WorkspaceMembership.Role.MEMBER,
|
},
|
||||||
is_active=True,
|
format="json",
|
||||||
)
|
)
|
||||||
WorkspaceMembership.objects.create(
|
create_entry_response = self.client.post(
|
||||||
workspace=workspace,
|
"/api/time-entries/",
|
||||||
user=guest,
|
{
|
||||||
role=WorkspaceMembership.Role.GUEST,
|
"workspace_id": str(self.workspace.id),
|
||||||
is_active=True,
|
"start_time": timezone.now().isoformat(),
|
||||||
)
|
"description": "Blocked guest entry",
|
||||||
return workspace
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
edit_project_response = self.client.patch(
|
||||||
|
f"/api/projects/{self.project.id}/",
|
||||||
|
{"description": "Blocked"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
@pytest.fixture()
|
def test_member_cannot_edit_project(self):
|
||||||
def project(workspace, owner, member):
|
self.client.force_authenticate(user=self.member)
|
||||||
return Project.objects.create(workspace=workspace, name="Alpha", description="")
|
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/projects/{self.project.id}/",
|
||||||
|
{"description": "Still blocked"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
def test_member_is_read_only_for_clients_and_projects(api_client, member, workspace, project):
|
self.assertEqual(response.status_code, 403)
|
||||||
client = Client.objects.create(workspace=workspace, name="Existing Client", notes="")
|
|
||||||
api_client.force_authenticate(user=member)
|
|
||||||
|
|
||||||
client_response = api_client.post(
|
def test_member_can_list_workspace_members_with_restricted_user_fields(self):
|
||||||
"/api/clients/",
|
self.client.force_authenticate(user=self.member)
|
||||||
{"workspace_id": str(workspace.id), "name": "Acme", "notes": ""},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
update_client_response = api_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(
|
|
||||||
"/api/projects/",
|
|
||||||
{"workspace": str(workspace.id), "name": "Beta", "description": "", "client": None},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
update_project_response = api_client.patch(
|
|
||||||
f"/api/projects/{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
|
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/workspace-memberships/?workspace={self.workspace.id}"
|
||||||
|
)
|
||||||
|
|
||||||
def test_member_can_create_tags_and_manage_own_time_entries(api_client, owner, member, workspace):
|
self.assertEqual(response.status_code, 200)
|
||||||
tag = Tag.objects.create(workspace=workspace, name="Existing", color="#000000")
|
payload = (
|
||||||
api_client.force_authenticate(user=member)
|
response.data.get("items", response.data)
|
||||||
|
if isinstance(response.data, dict)
|
||||||
|
else response.data
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(len(payload), 1)
|
||||||
|
first_user = payload[0]["user"]
|
||||||
|
self.assertNotIn("mobile", first_user)
|
||||||
|
self.assertNotIn("email", first_user)
|
||||||
|
|
||||||
create_tag_response = api_client.post(
|
def test_owner_can_list_workspace_members_with_full_user_fields(self):
|
||||||
"/api/tags/",
|
self.client.force_authenticate(user=self.owner)
|
||||||
{"workspace_id": str(workspace.id), "name": "New Tag", "color": "#ffffff"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
update_tag_response = api_client.patch(
|
|
||||||
f"/api/tags/{tag.id}/",
|
|
||||||
{"name": "Changed"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
delete_tag_response = api_client.delete(f"/api/tags/{tag.id}/")
|
|
||||||
|
|
||||||
now = timezone.now()
|
response = self.client.get(
|
||||||
create_entry_response = api_client.post(
|
f"/api/workspace-memberships/?workspace={self.workspace.id}"
|
||||||
"/api/time-entries/",
|
)
|
||||||
{
|
|
||||||
"workspace_id": str(workspace.id),
|
|
||||||
"start_time": now.isoformat(),
|
|
||||||
"end_time": (now + timedelta(hours=1)).isoformat(),
|
|
||||||
"description": "Focus block",
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert create_tag_response.status_code == 201
|
self.assertEqual(response.status_code, 200)
|
||||||
assert update_tag_response.status_code == 403
|
payload = (
|
||||||
assert delete_tag_response.status_code == 403
|
response.data.get("items", response.data)
|
||||||
assert create_entry_response.status_code == 201
|
if isinstance(response.data, dict)
|
||||||
|
else response.data
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(len(payload), 1)
|
||||||
|
first_user = payload[0]["user"]
|
||||||
|
self.assertIn("mobile", first_user)
|
||||||
|
|
||||||
entry_id = create_entry_response.data["id"]
|
def test_admin_cannot_change_owner_membership_but_canonical_owner_can(self):
|
||||||
update_entry_response = api_client.patch(
|
extra_owner_membership = WorkspaceMembership.objects.create(
|
||||||
f"/api/time-entries/{entry_id}/",
|
workspace=self.workspace,
|
||||||
{"description": "Updated focus block"},
|
user=self.extra_owner,
|
||||||
format="json",
|
role=WorkspaceMembership.Role.OWNER,
|
||||||
)
|
is_active=True,
|
||||||
delete_entry_response = api_client.delete(f"/api/time-entries/{entry_id}/")
|
)
|
||||||
|
|
||||||
assert update_entry_response.status_code == 200
|
self.client.force_authenticate(user=self.admin)
|
||||||
assert delete_entry_response.status_code == 204
|
admin_response = self.client.patch(
|
||||||
|
f"/api/workspace-memberships/{extra_owner_membership.id}/",
|
||||||
|
{"role": WorkspaceMembership.Role.ADMIN},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
def test_guest_is_read_only_for_workspace_resources(api_client, owner, guest, workspace, project):
|
self.assertEqual(admin_response.status_code, 403)
|
||||||
Client.objects.create(workspace=workspace, name="Visible Client", notes="")
|
self.assertEqual(owner_response.status_code, 200)
|
||||||
Tag.objects.create(workspace=workspace, name="Visible Tag", color="#123456")
|
|
||||||
|
|
||||||
api_client.force_authenticate(user=guest)
|
def test_admin_cannot_add_or_change_admin_memberships(self):
|
||||||
|
admin_membership = WorkspaceMembership.objects.get(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.admin,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
|
||||||
list_clients_response = api_client.get(f"/api/clients/?workspace={workspace.id}")
|
self.client.force_authenticate(user=self.admin)
|
||||||
list_projects_response = api_client.get(f"/api/projects/?workspace={workspace.id}")
|
create_response = self.client.post(
|
||||||
create_tag_response = api_client.post(
|
"/api/workspace-memberships/",
|
||||||
"/api/tags/",
|
{
|
||||||
{"workspace_id": str(workspace.id), "name": "Blocked", "color": "#ffffff"},
|
"workspace": str(self.workspace.id),
|
||||||
format="json",
|
"user": str(self.member.id),
|
||||||
)
|
"role": WorkspaceMembership.Role.ADMIN,
|
||||||
create_entry_response = api_client.post(
|
},
|
||||||
"/api/time-entries/",
|
format="json",
|
||||||
{
|
)
|
||||||
"workspace_id": str(workspace.id),
|
update_response = self.client.patch(
|
||||||
"start_time": timezone.now().isoformat(),
|
f"/api/workspace-memberships/{admin_membership.id}/",
|
||||||
"description": "Blocked guest entry",
|
{"role": WorkspaceMembership.Role.MEMBER},
|
||||||
},
|
format="json",
|
||||||
format="json",
|
)
|
||||||
)
|
delete_response = self.client.delete(
|
||||||
edit_project_response = api_client.patch(
|
f"/api/workspace-memberships/{admin_membership.id}/"
|
||||||
f"/api/projects/{project.id}/",
|
)
|
||||||
{"description": "Blocked"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert list_clients_response.status_code == 200
|
self.assertEqual(create_response.status_code, 403)
|
||||||
assert list_projects_response.status_code == 200
|
self.assertEqual(update_response.status_code, 403)
|
||||||
assert create_tag_response.status_code == 403
|
self.assertEqual(delete_response.status_code, 403)
|
||||||
assert create_entry_response.status_code == 403
|
|
||||||
assert edit_project_response.status_code == 403
|
|
||||||
|
|
||||||
|
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(self.workspace.id),
|
||||||
|
"name": "Owner Client",
|
||||||
|
"notes": "",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
owner_tag_response = self.client.post(
|
||||||
|
"/api/tags/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": "Owner Tag",
|
||||||
|
"color": "#123456",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
owner_project_response = self.client.post(
|
||||||
|
"/api/projects/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"name": "Owner Project",
|
||||||
|
"description": "",
|
||||||
|
"client": None,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
def test_member_cannot_edit_project(api_client, member, project):
|
self.client.force_authenticate(user=self.admin)
|
||||||
api_client.force_authenticate(user=member)
|
admin_client_response = self.client.post(
|
||||||
|
"/api/clients/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": "Admin Client",
|
||||||
|
"notes": "",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
admin_tag_response = self.client.post(
|
||||||
|
"/api/tags/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": "Admin Tag",
|
||||||
|
"color": "#654321",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
admin_project_response = self.client.post(
|
||||||
|
"/api/projects/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"name": "Admin Project",
|
||||||
|
"description": "",
|
||||||
|
"client": None,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
response = api_client.patch(
|
delete_owner_client = self.client.delete(
|
||||||
f"/api/projects/{project.id}/",
|
f"/api/clients/{owner_client_response.data['id']}/"
|
||||||
{"description": "Still blocked"},
|
)
|
||||||
format="json",
|
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']}/"
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
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']}/"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(delete_owner_client.status_code, 403)
|
||||||
def test_member_can_list_workspace_members_with_restricted_user_fields(api_client, member, workspace):
|
self.assertEqual(delete_owner_tag.status_code, 403)
|
||||||
api_client.force_authenticate(user=member)
|
self.assertIn(delete_owner_project.status_code, {403, 404})
|
||||||
|
self.assertEqual(delete_admin_client.status_code, 204)
|
||||||
response = api_client.get(f"/api/workspace-memberships/?workspace={workspace.id}")
|
self.assertEqual(delete_admin_tag.status_code, 204)
|
||||||
|
self.assertEqual(delete_admin_project.status_code, 204)
|
||||||
assert response.status_code == 200
|
|
||||||
payload = response.data.get("items", response.data) if isinstance(response.data, dict) else response.data
|
|
||||||
assert len(payload) >= 1
|
|
||||||
first_user = payload[0]["user"]
|
|
||||||
assert "mobile" not in first_user
|
|
||||||
assert "email" not in first_user
|
|
||||||
|
|
||||||
|
|
||||||
def test_owner_can_list_workspace_members_with_full_user_fields(api_client, owner, workspace):
|
|
||||||
api_client.force_authenticate(user=owner)
|
|
||||||
|
|
||||||
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
|
|
||||||
first_user = payload[0]["user"]
|
|
||||||
assert "mobile" in first_user
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_cannot_change_owner_membership_but_canonical_owner_can(
|
|
||||||
api_client, owner, admin, extra_owner, workspace
|
|
||||||
):
|
|
||||||
extra_owner_membership = WorkspaceMembership.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=extra_owner,
|
|
||||||
role=WorkspaceMembership.Role.OWNER,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
api_client.force_authenticate(user=admin)
|
|
||||||
admin_response = api_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(
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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(
|
|
||||||
"/api/workspace-memberships/",
|
|
||||||
{
|
|
||||||
"workspace": str(workspace.id),
|
|
||||||
"user": str(member.id),
|
|
||||||
"role": WorkspaceMembership.Role.ADMIN,
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
update_response = api_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}/")
|
|
||||||
|
|
||||||
assert create_response.status_code == 403
|
|
||||||
assert update_response.status_code == 403
|
|
||||||
assert 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(
|
|
||||||
"/api/clients/",
|
|
||||||
{"workspace_id": str(workspace.id), "name": "Owner Client", "notes": ""},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
owner_tag_response = api_client.post(
|
|
||||||
"/api/tags/",
|
|
||||||
{"workspace_id": str(workspace.id), "name": "Owner Tag", "color": "#123456"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
owner_project_response = api_client.post(
|
|
||||||
"/api/projects/",
|
|
||||||
{"workspace": str(workspace.id), "name": "Owner Project", "description": "", "client": None},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
api_client.force_authenticate(user=admin)
|
|
||||||
admin_client_response = api_client.post(
|
|
||||||
"/api/clients/",
|
|
||||||
{"workspace_id": str(workspace.id), "name": "Admin Client", "notes": ""},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
admin_tag_response = api_client.post(
|
|
||||||
"/api/tags/",
|
|
||||||
{"workspace_id": str(workspace.id), "name": "Admin Tag", "color": "#654321"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
admin_project_response = api_client.post(
|
|
||||||
"/api/projects/",
|
|
||||||
{"workspace": str(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_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']}/")
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,128 +1,254 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
from django.core.cache import cache
|
||||||
from rest_framework.test import APIClient
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
from apps.time_entries.services.rates import resolve_rate
|
from apps.time_entries.services.rates import resolve_rate
|
||||||
from apps.users.models import User
|
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()
|
class WorkspaceRateTests(APITestCase):
|
||||||
def api_client():
|
@classmethod
|
||||||
return APIClient()
|
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")
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
def test_resolve_rate_uses_workspace_user_rate(self):
|
||||||
|
WorkspaceUserRate.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.member,
|
||||||
|
hourly_rate=Decimal("40.00"),
|
||||||
|
currency="EUR",
|
||||||
|
effective_from=self.project.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
hourly_rate, currency = resolve_rate(self.member, self.project)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
self.assertIsNone(hourly_rate)
|
||||||
|
self.assertEqual(currency, "")
|
||||||
|
|
||||||
|
def test_admin_can_manage_workspace_user_rates(self):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/workspace-user-rates/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"user_id": str(self.member.id),
|
||||||
|
"hourly_rate": "35.50",
|
||||||
|
"currency": "USD",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(create_response.status_code, 201)
|
||||||
|
rate_id = create_response.data["id"]
|
||||||
|
self.assertTrue(
|
||||||
|
WorkspaceUserRate.objects.filter(id=rate_id, is_deleted=False).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
update_response = self.client.patch(
|
||||||
|
f"/api/workspace-user-rates/{rate_id}/",
|
||||||
|
{"hourly_rate": "42.00"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(update_response.status_code, 200)
|
||||||
|
self.assertEqual(update_response.data["hourly_rate"], "42.00")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/workspace-user-rates/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"user_id": str(self.member.id),
|
||||||
|
"hourly_rate": "25.00",
|
||||||
|
"currency": "USD",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class WorkspaceRateServiceTests(TestCase):
|
||||||
def owner(db):
|
@classmethod
|
||||||
return User.objects.create_user(mobile="09127770001", password="secret123")
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.fixture()
|
self.assertEqual(rate.hourly_rate, Decimal("12.50"))
|
||||||
def admin(db):
|
self.assertEqual(rate.currency, "USD")
|
||||||
return User.objects.create_user(mobile="09127770002", password="secret123")
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.fixture()
|
updated = upsert_workspace_user_rate(
|
||||||
def member(db):
|
self.workspace,
|
||||||
return User.objects.create_user(mobile="09127770003", password="secret123")
|
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)
|
||||||
|
|
||||||
@pytest.fixture()
|
def test_update_workspace_user_rate_updates_only_changed_fields(self):
|
||||||
def workspace(owner, admin, member):
|
rate = WorkspaceUserRate.objects.create(
|
||||||
workspace = Workspace.objects.create(name="Rates", owner=owner)
|
workspace=self.workspace,
|
||||||
WorkspaceMembership.objects.create(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True)
|
user=self.member,
|
||||||
WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True)
|
hourly_rate=Decimal("10.00"),
|
||||||
return workspace
|
currency="USD",
|
||||||
|
effective_from=self.workspace.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = update_workspace_user_rate(
|
||||||
|
rate,
|
||||||
|
hourly_rate=Decimal("15.00"),
|
||||||
|
currency="gbp",
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.fixture()
|
self.assertEqual(updated.hourly_rate, Decimal("15.00"))
|
||||||
def project(workspace, owner, admin, member):
|
self.assertEqual(updated.currency, "GBP")
|
||||||
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):
|
|
||||||
WorkspaceUserRate.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=member,
|
|
||||||
hourly_rate=Decimal("40.00"),
|
|
||||||
currency="EUR",
|
|
||||||
effective_from=project.created_at,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
hourly_rate, currency = resolve_rate(member, project)
|
|
||||||
|
|
||||||
assert hourly_rate == Decimal("40.00")
|
|
||||||
assert currency == "EUR"
|
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
hourly_rate, currency = resolve_rate(member, project)
|
|
||||||
|
|
||||||
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(
|
|
||||||
"/api/workspace-user-rates/",
|
|
||||||
{
|
|
||||||
"workspace_id": str(workspace.id),
|
|
||||||
"user_id": str(member.id),
|
|
||||||
"hourly_rate": "35.50",
|
|
||||||
"currency": "USD",
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert create_response.status_code == 201
|
|
||||||
rate_id = create_response.data["id"]
|
|
||||||
assert WorkspaceUserRate.objects.filter(id=rate_id, is_deleted=False).exists()
|
|
||||||
|
|
||||||
update_response = api_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"
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_member_cannot_manage_rates(api_client, member, workspace, price_units):
|
|
||||||
api_client.force_authenticate(user=member)
|
|
||||||
|
|
||||||
workspace_response = api_client.post(
|
|
||||||
"/api/workspace-user-rates/",
|
|
||||||
{
|
|
||||||
"workspace_id": str(workspace.id),
|
|
||||||
"user_id": str(member.id),
|
|
||||||
"hourly_rate": "25.00",
|
|
||||||
"currency": "USD",
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert workspace_response.status_code == 403
|
|
||||||
|
|||||||
@@ -131,6 +131,14 @@ REST_FRAMEWORK = {
|
|||||||
"rest_framework.throttling.AnonRateThrottle",
|
"rest_framework.throttling.AnonRateThrottle",
|
||||||
"rest_framework.throttling.UserRateThrottle",
|
"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",
|
"EXCEPTION_HANDLER": "core.exceptions.handlers.exception_handler",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,5 +256,9 @@ STORAGES = {
|
|||||||
|
|
||||||
SMS_APIKEY = os.getenv("SMS_APIKEY", "")
|
SMS_APIKEY = os.getenv("SMS_APIKEY", "")
|
||||||
BASE_URL = os.getenv("BASE_URL", "")
|
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
|
from config.services.auditlog import * # noqa: E402,F401,F403
|
||||||
|
|||||||
@@ -28,3 +28,4 @@ CELERY_TASK_ALWAYS_EAGER = True
|
|||||||
CELERY_TASK_EAGER_PROPAGATES = True
|
CELERY_TASK_EAGER_PROPAGATES = True
|
||||||
|
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
|
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 logging
|
||||||
import traceback
|
import traceback
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework import status as http_status
|
from rest_framework import status as http_status
|
||||||
from rest_framework.exceptions import ErrorDetail
|
from rest_framework.exceptions import ErrorDetail
|
||||||
|
from rest_framework.exceptions import Throttled
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import exception_handler as drf_exception_handler
|
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:
|
if status_code < 500:
|
||||||
messages = _to_str_list(detail)
|
messages = _to_str_list(detail)
|
||||||
payload = _format_payload(messages, status_code)
|
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()
|
traceback_text = traceback.format_exc()
|
||||||
payload = _format_payload(
|
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
|
|
||||||
@@ -7,10 +7,9 @@ ipython>=8.25
|
|||||||
# Debug toolbar
|
# Debug toolbar
|
||||||
django-debug-toolbar>=4.4
|
django-debug-toolbar>=4.4
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
pytest>=8.2
|
coverage>=7.10
|
||||||
pytest-django>=4.8
|
factory-boy>=3.3
|
||||||
factory-boy>=3.3
|
|
||||||
|
|
||||||
# Linting & formatting
|
# Linting & formatting
|
||||||
black>=24.4
|
black>=24.4
|
||||||
@@ -22,4 +21,4 @@ django-stubs>=5.0
|
|||||||
|
|
||||||
# Pre-commit hooks
|
# Pre-commit hooks
|
||||||
pre-commit>=3.7
|
pre-commit>=3.7
|
||||||
commitizen>=4.13
|
commitizen>=4.13
|
||||||
|
|||||||
Reference in New Issue
Block a user