Compare commits
50 Commits
71924ce6fb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 027afb7e23 | |||
| 170ec90ec1 | |||
| 30a324c6f4 | |||
| da40720a0f | |||
| 948a8e1b75 | |||
| b5ddcb76aa | |||
| 20874b9968 | |||
| af9facce7e | |||
| e42e0612aa | |||
| f99e883f12 | |||
| d18fdb1454 | |||
| 5500badc6a | |||
| 2a0fa22be6 | |||
| 22e08a099c | |||
| 59cf62bc73 | |||
| 0d6c6a4f09 | |||
| 181a135df9 | |||
| b79fd73403 | |||
| 4d05d4d590 | |||
| 8d2f876c82 | |||
| e234eac26d | |||
| 0fea265cfb | |||
| 4a6f6a08fb | |||
| 837f5bb49e | |||
| aa0b0c8686 | |||
| 3019f59d3a | |||
| 388d4e0e7f | |||
| d75c19bb6b | |||
| cacf6114d1 | |||
| 09d2015351 | |||
| bb06762377 | |||
| d4a52d6f3b | |||
| 77c07adec8 | |||
| f9c4c06531 | |||
| d1c4889d22 | |||
| f04e9ba828 | |||
| 8ff1e4fa61 | |||
| 0823267544 | |||
| df9a183823 | |||
| fb15a16204 | |||
| 99eb4c2594 | |||
| 054bb5a582 | |||
| 08e1793765 | |||
| 3152284cf3 | |||
| 8774a4d4dc | |||
| 204225dd16 | |||
| a2de2a133c | |||
| ec199a0e99 | |||
| ef05f0a89e | |||
| 1cd948592c |
11
.coveragerc
Normal file
11
.coveragerc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[run]
|
||||||
|
branch = True
|
||||||
|
source =
|
||||||
|
apps
|
||||||
|
omit =
|
||||||
|
*/migrations/*
|
||||||
|
*/tests/*
|
||||||
|
|
||||||
|
[report]
|
||||||
|
show_missing = True
|
||||||
|
skip_covered = False
|
||||||
@@ -42,3 +42,7 @@ TIME_ZONE=Asia/Tehran
|
|||||||
|
|
||||||
SMS_APIKEY=
|
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
|
||||||
|
|||||||
82
.gitea/workflows/backend.yml
Normal file
82
.gitea/workflows/backend.yml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Backend CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: qlockify-python
|
||||||
|
steps:
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends git
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
env:
|
||||||
|
REPO_URL: ${{ gitea.server_url }}/${{ gitea.repository }}.git
|
||||||
|
REPO_SHA: ${{ gitea.sha }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
WORKSPACE: ${{ gitea.workspace }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$WORKSPACE"
|
||||||
|
cd "$WORKSPACE"
|
||||||
|
git init
|
||||||
|
git remote add origin "$REPO_URL"
|
||||||
|
git -c http.extraHeader="Authorization: Bearer $GITEA_TOKEN" fetch --depth 1 origin "$REPO_SHA"
|
||||||
|
git checkout --detach FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
working-directory: ${{ gitea.workspace }}
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -r requirements/base.txt -r requirements/dev.txt
|
||||||
|
|
||||||
|
- name: Lint backend
|
||||||
|
working-directory: ${{ gitea.workspace }}
|
||||||
|
run: python -m ruff check .
|
||||||
|
|
||||||
|
- name: Run backend tests
|
||||||
|
working-directory: ${{ gitea.workspace }}
|
||||||
|
run: python manage.py test --settings=config.settings.test
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
if: github.event_name == 'push' && github.ref_name == 'main'
|
||||||
|
needs:
|
||||||
|
- test
|
||||||
|
runs-on: qlockify-deploy
|
||||||
|
steps:
|
||||||
|
- name: Install SSH client
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends bash openssh-client
|
||||||
|
|
||||||
|
- name: Configure SSH
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
|
run: |
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||||
|
chmod 644 ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Deploy backend services
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||||
|
DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
|
||||||
|
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
||||||
|
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
|
||||||
|
DEPLOY_BRANCH: ${{ vars.DEPLOY_BRANCH }}
|
||||||
|
BACKEND_BRANCH: ${{ vars.BACKEND_BRANCH }}
|
||||||
|
FRONTEND_BRANCH: ${{ vars.FRONTEND_BRANCH }}
|
||||||
|
run: |
|
||||||
|
ssh -p "${DEPLOY_PORT:-22}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
|
||||||
|
"DEPLOY_ROOT='${DEPLOY_PATH}' DEPLOY_BRANCH='${DEPLOY_BRANCH}' BACKEND_BRANCH='${BACKEND_BRANCH}' FRONTEND_BRANCH='${FRONTEND_BRANCH}' bash '${DEPLOY_PATH}/scripts/deploy.sh' backend"
|
||||||
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
|
||||||
@@ -13,9 +13,33 @@ class ClientSerializer(BaseModelSerializer):
|
|||||||
"workspace",
|
"workspace",
|
||||||
"name",
|
"name",
|
||||||
"notes",
|
"notes",
|
||||||
|
"thumbnail",
|
||||||
)
|
)
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
data = super().to_representation(instance)
|
||||||
|
request = self.context.get("request")
|
||||||
|
if instance.thumbnail:
|
||||||
|
thumbnail_url = instance.thumbnail.url
|
||||||
|
data["thumbnail"] = request.build_absolute_uri(thumbnail_url) if request else thumbnail_url
|
||||||
|
else:
|
||||||
|
data["thumbnail"] = None
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def validate_thumbnail(value):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
max_bytes = 2 * 1024 * 1024
|
||||||
|
if getattr(value, "size", 0) > max_bytes:
|
||||||
|
raise serializers.ValidationError("Image size must be 2MB or less.")
|
||||||
|
content_type = (getattr(value, "content_type", "") or "").lower()
|
||||||
|
allowed_types = {"image/jpeg", "image/png", "image/webp"}
|
||||||
|
if content_type and content_type not in allowed_types:
|
||||||
|
raise serializers.ValidationError("Unsupported image type. Use JPG, PNG, or WebP.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ClientCreateSerializer(serializers.Serializer):
|
class ClientCreateSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
@@ -24,6 +48,10 @@ class ClientCreateSerializer(serializers.Serializer):
|
|||||||
workspace_id = serializers.UUIDField()
|
workspace_id = serializers.UUIDField()
|
||||||
name = serializers.CharField(max_length=255)
|
name = serializers.CharField(max_length=255)
|
||||||
notes = serializers.CharField(allow_blank=True, required=False, default="")
|
notes = serializers.CharField(allow_blank=True, required=False, default="")
|
||||||
|
thumbnail = serializers.ImageField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
def validate_thumbnail(self, value):
|
||||||
|
return validate_thumbnail(value)
|
||||||
|
|
||||||
|
|
||||||
class ClientUpdateSerializer(serializers.Serializer):
|
class ClientUpdateSerializer(serializers.Serializer):
|
||||||
@@ -32,3 +60,8 @@ class ClientUpdateSerializer(serializers.Serializer):
|
|||||||
"""
|
"""
|
||||||
name = serializers.CharField(max_length=255, required=False)
|
name = serializers.CharField(max_length=255, required=False)
|
||||||
notes = serializers.CharField(allow_blank=True, required=False)
|
notes = serializers.CharField(allow_blank=True, required=False)
|
||||||
|
thumbnail = serializers.ImageField(required=False, allow_null=True)
|
||||||
|
clear_thumbnail = serializers.BooleanField(required=False, default=False)
|
||||||
|
|
||||||
|
def validate_thumbnail(self, value):
|
||||||
|
return validate_thumbnail(value)
|
||||||
|
|||||||
@@ -63,10 +63,11 @@ class ClientViewSet(ModelViewSet):
|
|||||||
user=request.user,
|
user=request.user,
|
||||||
workspace_id=serializer.validated_data["workspace_id"],
|
workspace_id=serializer.validated_data["workspace_id"],
|
||||||
name=serializer.validated_data["name"],
|
name=serializer.validated_data["name"],
|
||||||
notes=serializer.validated_data.get("notes", "")
|
notes=serializer.validated_data.get("notes", ""),
|
||||||
|
thumbnail=serializer.validated_data.get("thumbnail"),
|
||||||
)
|
)
|
||||||
|
|
||||||
output_serializer = ClientSerializer(client)
|
output_serializer = ClientSerializer(client, context={"request": request})
|
||||||
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
@@ -82,10 +83,12 @@ class ClientViewSet(ModelViewSet):
|
|||||||
updated_client = update_client(
|
updated_client = update_client(
|
||||||
client=client,
|
client=client,
|
||||||
name=serializer.validated_data.get("name"),
|
name=serializer.validated_data.get("name"),
|
||||||
notes=serializer.validated_data.get("notes")
|
notes=serializer.validated_data.get("notes"),
|
||||||
|
thumbnail=serializer.validated_data.get("thumbnail"),
|
||||||
|
clear_thumbnail=serializer.validated_data.get("clear_thumbnail", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
output_serializer = ClientSerializer(updated_client)
|
output_serializer = ClientSerializer(updated_client, context={"request": request})
|
||||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
|||||||
18
apps/clients/migrations/0002_client_thumbnail.py
Normal file
18
apps/clients/migrations/0002_client_thumbnail.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-05-26 08:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('clients', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='client',
|
||||||
|
name='thumbnail',
|
||||||
|
field=models.ImageField(blank=True, null=True, upload_to='profile/clients/'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -17,6 +17,8 @@ class Client(BaseModel):
|
|||||||
|
|
||||||
notes = models.TextField(blank=True)
|
notes = models.TextField(blank=True)
|
||||||
|
|
||||||
|
thumbnail = models.ImageField(upload_to="profile/clients/", blank=True, null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "client"
|
db_table = "client"
|
||||||
ordering = ("-updated_at", "-created_at")
|
ordering = ("-updated_at", "-created_at")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from apps.clients.models import Client
|
|||||||
from apps.workspaces.models import WorkspaceMembership
|
from apps.workspaces.models import WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
def create_client(user, workspace_id, name, notes=""):
|
def create_client(user, workspace_id, name, notes="", thumbnail=None):
|
||||||
"""
|
"""
|
||||||
Creates a new client after validating workspace membership and name uniqueness.
|
Creates a new client after validating workspace membership and name uniqueness.
|
||||||
"""
|
"""
|
||||||
@@ -23,12 +23,13 @@ def create_client(user, workspace_id, name, notes=""):
|
|||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
name=name,
|
name=name,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
|
thumbnail=thumbnail,
|
||||||
created_by=user,
|
created_by=user,
|
||||||
updated_by=user,
|
updated_by=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def update_client(client, name=None, notes=None):
|
def update_client(client, name=None, notes=None, thumbnail=None, clear_thumbnail=False):
|
||||||
"""
|
"""
|
||||||
Updates an existing client while validating name uniqueness within the workspace.
|
Updates an existing client while validating name uniqueness within the workspace.
|
||||||
"""
|
"""
|
||||||
@@ -40,5 +41,17 @@ def update_client(client, name=None, notes=None):
|
|||||||
if notes is not None:
|
if notes is not None:
|
||||||
client.notes = notes
|
client.notes = notes
|
||||||
|
|
||||||
client.save(update_fields=["name", "notes", "updated_at"])
|
old_thumbnail_name = client.thumbnail.name if client.thumbnail else None
|
||||||
|
if clear_thumbnail and client.thumbnail:
|
||||||
|
client.thumbnail.delete(save=False)
|
||||||
|
client.thumbnail = None
|
||||||
|
|
||||||
|
if thumbnail is not None:
|
||||||
|
client.thumbnail = thumbnail
|
||||||
|
|
||||||
|
client.save(update_fields=["name", "notes", "thumbnail", "updated_at"])
|
||||||
|
if old_thumbnail_name and client.thumbnail and client.thumbnail.name != old_thumbnail_name:
|
||||||
|
storage = client.thumbnail.storage
|
||||||
|
if storage.exists(old_thumbnail_name):
|
||||||
|
storage.delete(old_thumbnail_name)
|
||||||
return client
|
return client
|
||||||
|
|||||||
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
apps/contacts/__init__.py
Normal file
1
apps/contacts/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
56
apps/contacts/admin.py
Normal file
56
apps/contacts/admin.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.contacts.models import ContactSubmission
|
||||||
|
from core.admins.base import BaseAdmin, SoftDeleteListFilter
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ContactSubmission)
|
||||||
|
class ContactSubmissionAdmin(BaseAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"full_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
"is_deleted",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
SoftDeleteListFilter,
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
)
|
||||||
|
readonly_fields = (
|
||||||
|
"id",
|
||||||
|
"ip_address",
|
||||||
|
"user_agent",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
)
|
||||||
|
fields = (
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
"status",
|
||||||
|
"ip_address",
|
||||||
|
"user_agent",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.display(description="Full name")
|
||||||
|
def full_name(self, obj):
|
||||||
|
return f"{obj.first_name} {obj.last_name}".strip()
|
||||||
1
apps/contacts/api/__init__.py
Normal file
1
apps/contacts/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
43
apps/contacts/api/serializers.py
Normal file
43
apps/contacts/api/serializers.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.contacts.models import ContactSubmission
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionCreateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ContactSubmission
|
||||||
|
fields = (
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_mobile(self, value):
|
||||||
|
clean_value = value.strip()
|
||||||
|
if len(clean_value) < 8:
|
||||||
|
raise serializers.ValidationError("Enter a valid mobile number.")
|
||||||
|
return clean_value
|
||||||
|
|
||||||
|
def validate_message(self, value):
|
||||||
|
clean_value = value.strip()
|
||||||
|
if len(clean_value) < 10:
|
||||||
|
raise serializers.ValidationError("Message must be at least 10 characters.")
|
||||||
|
return clean_value
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionResponseSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ContactSubmission
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
read_only_fields = fields
|
||||||
5
apps/contacts/api/throttles.py
Normal file
5
apps/contacts/api/throttles.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionThrottle(AnonRateThrottle):
|
||||||
|
scope = "contact_submission"
|
||||||
9
apps/contacts/api/urls.py
Normal file
9
apps/contacts/api/urls.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.contacts.api.views import ContactSubmissionView
|
||||||
|
|
||||||
|
app_name = "contacts"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", ContactSubmissionView.as_view(), name="contact-submit"),
|
||||||
|
]
|
||||||
40
apps/contacts/api/views.py
Normal file
40
apps/contacts/api/views.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.contacts.api.serializers import (
|
||||||
|
ContactSubmissionCreateSerializer,
|
||||||
|
ContactSubmissionResponseSerializer,
|
||||||
|
)
|
||||||
|
from apps.contacts.api.throttles import ContactSubmissionThrottle
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_ip(request):
|
||||||
|
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||||
|
if forwarded_for:
|
||||||
|
return forwarded_for.split(",")[0].strip()
|
||||||
|
return request.META.get("REMOTE_ADDR")
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionView(APIView):
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
throttle_classes = (ContactSubmissionThrottle,)
|
||||||
|
serializer_class = ContactSubmissionCreateSerializer
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=ContactSubmissionCreateSerializer,
|
||||||
|
responses={201: ContactSubmissionResponseSerializer},
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
serializer = self.serializer_class(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
submission = serializer.save(
|
||||||
|
ip_address=_get_client_ip(request),
|
||||||
|
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
ContactSubmissionResponseSerializer(submission).data,
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
6
apps/contacts/apps.py
Normal file
6
apps/contacts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ContactsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.contacts"
|
||||||
85
apps/contacts/migrations/0001_initial.py
Normal file
85
apps/contacts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Generated manually for contact submissions.
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ContactSubmission",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid7,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("is_deleted", models.BooleanField(default=False)),
|
||||||
|
("is_active", models.BooleanField(default=False)),
|
||||||
|
("first_name", models.CharField(max_length=120)),
|
||||||
|
("last_name", models.CharField(max_length=120)),
|
||||||
|
("email", models.EmailField(max_length=254)),
|
||||||
|
("mobile", models.CharField(max_length=32)),
|
||||||
|
("message", models.TextField()),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("new", "New"),
|
||||||
|
("contacted", "Contacted"),
|
||||||
|
("closed", "Closed"),
|
||||||
|
("spam", "Spam"),
|
||||||
|
],
|
||||||
|
default="new",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
("user_agent", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="created_%(app_label)s_%(class)s_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="updated_%(app_label)s_%(class)s_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "contact_submission",
|
||||||
|
"ordering": ("-created_at",),
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["id"], name="contactsubmission_id_idx"),
|
||||||
|
models.Index(fields=["created_at"], name="contact_created_at_idx"),
|
||||||
|
models.Index(fields=["status"], name="contact_status_idx"),
|
||||||
|
models.Index(fields=["email"], name="contact_email_idx"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
1
apps/contacts/migrations/__init__.py
Normal file
1
apps/contacts/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
36
apps/contacts/models.py
Normal file
36
apps/contacts/models.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from core.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmission(BaseModel):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
NEW = "new", "New"
|
||||||
|
CONTACTED = "contacted", "Contacted"
|
||||||
|
CLOSED = "closed", "Closed"
|
||||||
|
SPAM = "spam", "Spam"
|
||||||
|
|
||||||
|
first_name = models.CharField(max_length=120)
|
||||||
|
last_name = models.CharField(max_length=120)
|
||||||
|
email = models.EmailField()
|
||||||
|
mobile = models.CharField(max_length=32)
|
||||||
|
message = models.TextField()
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=Status.choices,
|
||||||
|
default=Status.NEW,
|
||||||
|
)
|
||||||
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
user_agent = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "contact_submission"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
indexes = (
|
||||||
|
models.Index(fields=("created_at",), name="contact_created_at_idx"),
|
||||||
|
models.Index(fields=("status",), name="contact_status_idx"),
|
||||||
|
models.Index(fields=("email",), name="contact_email_idx"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.first_name} {self.last_name} - {self.email}"
|
||||||
1
apps/contacts/tests/__init__.py
Normal file
1
apps/contacts/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
44
apps/contacts/tests/test_api_views.py
Normal file
44
apps/contacts/tests/test_api_views.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from apps.contacts.models import ContactSubmission
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionApiTests(APITestCase):
|
||||||
|
def test_public_user_can_submit_contact_form(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("contacts:contact-submit"),
|
||||||
|
{
|
||||||
|
"first_name": "Amin",
|
||||||
|
"last_name": "Test",
|
||||||
|
"email": "amin@example.com",
|
||||||
|
"mobile": "09938228438",
|
||||||
|
"message": "I need help with Qlockify reports.",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
HTTP_X_FORWARDED_FOR="203.0.113.10",
|
||||||
|
HTTP_USER_AGENT="test-agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
submission = ContactSubmission.objects.get()
|
||||||
|
self.assertEqual(submission.email, "amin@example.com")
|
||||||
|
self.assertEqual(submission.ip_address, "203.0.113.10")
|
||||||
|
self.assertEqual(submission.user_agent, "test-agent")
|
||||||
|
|
||||||
|
def test_rejects_short_message(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("contacts:contact-submit"),
|
||||||
|
{
|
||||||
|
"first_name": "Amin",
|
||||||
|
"last_name": "Test",
|
||||||
|
"email": "amin@example.com",
|
||||||
|
"mobile": "09938228438",
|
||||||
|
"message": "Hi",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertFalse(ContactSubmission.objects.exists())
|
||||||
1
apps/demos/__init__.py
Normal file
1
apps/demos/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
apps/demos/api/__init__.py
Normal file
1
apps/demos/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
apps/demos/api/throttles.py
Normal file
5
apps/demos/api/throttles.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
|
|
||||||
|
|
||||||
|
class DemoStartThrottle(AnonRateThrottle):
|
||||||
|
scope = "demo_start"
|
||||||
9
apps/demos/api/urls.py
Normal file
9
apps/demos/api/urls.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.demos.api.views import DemoStartView
|
||||||
|
|
||||||
|
app_name = "demos"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("start/", DemoStartView.as_view(), name="demo-start"),
|
||||||
|
]
|
||||||
29
apps/demos/api/views.py
Normal file
29
apps/demos/api/views.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
|
from rest_framework import serializers, status
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.demos.api.throttles import DemoStartThrottle
|
||||||
|
from apps.demos.services import create_demo_environment
|
||||||
|
|
||||||
|
|
||||||
|
class DemoStartView(APIView):
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
throttle_classes = (DemoStartThrottle,)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=None,
|
||||||
|
responses=inline_serializer(
|
||||||
|
name="DemoStartResponse",
|
||||||
|
fields={
|
||||||
|
"access": serializers.CharField(),
|
||||||
|
"refresh": serializers.CharField(),
|
||||||
|
"workspace_id": serializers.CharField(),
|
||||||
|
"expires_at": serializers.DateTimeField(),
|
||||||
|
"demo_environment_id": serializers.CharField(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
return Response(create_demo_environment(), status=status.HTTP_201_CREATED)
|
||||||
6
apps/demos/apps.py
Normal file
6
apps/demos/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DemosConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.demos"
|
||||||
1
apps/demos/management/__init__.py
Normal file
1
apps/demos/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
apps/demos/management/commands/__init__.py
Normal file
1
apps/demos/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
18
apps/demos/management/commands/cleanup_demo_environments.py
Normal file
18
apps/demos/management/commands/cleanup_demo_environments.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.demos.services import cleanup_expired_demo_environments
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Clean up expired isolated demo environments."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--expired", action="store_true", help="Clean expired demo environments.")
|
||||||
|
parser.add_argument("--batch-size", type=int, default=None, help="Maximum number of environments to clean.")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if not options["expired"]:
|
||||||
|
self.stderr.write("Only --expired cleanup is supported.")
|
||||||
|
return
|
||||||
|
cleaned = cleanup_expired_demo_environments(batch_size=options["batch_size"])
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Cleaned {cleaned} expired demo environment(s)."))
|
||||||
97
apps/demos/migrations/0001_initial.py
Normal file
97
apps/demos/migrations/0001_initial.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("workspaces", "0008_hourlyratehistory"),
|
||||||
|
("users", "0004_user_demo_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DemoEnvironment",
|
||||||
|
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)),
|
||||||
|
("expires_at", models.DateTimeField()),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("active", "Active"), ("expired", "Expired"), ("cleaned", "Cleaned")],
|
||||||
|
default="active",
|
||||||
|
max_length=16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("seed_version", models.CharField(default="v1", max_length=32)),
|
||||||
|
("cleaned_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("cleanup_error", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="created_demos_demoenvironment_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"owner_user",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="demo_environment",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="updated_demos_demoenvironment_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workspace",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="demo_environment",
|
||||||
|
to="workspaces.workspace",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "demo_environment",
|
||||||
|
"ordering": ("-created_at",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="demoenvironment",
|
||||||
|
index=models.Index(fields=["id"], name="demoenvironment_id_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="demoenvironment",
|
||||||
|
index=models.Index(fields=["status", "expires_at"], name="demo_status_expires_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="demoenvironment",
|
||||||
|
index=models.Index(fields=["owner_user"], name="demo_owner_user_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="demoenvironment",
|
||||||
|
index=models.Index(fields=["workspace"], name="demo_workspace_idx"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-06-06 21:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('demos', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name='demoenvironment',
|
||||||
|
name='demoenvironment_id_idx',
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='demoenvironment',
|
||||||
|
name='created_by',
|
||||||
|
field=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),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='demoenvironment',
|
||||||
|
name='updated_by',
|
||||||
|
field=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),
|
||||||
|
),
|
||||||
|
]
|
||||||
1
apps/demos/migrations/__init__.py
Normal file
1
apps/demos/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
39
apps/demos/models.py
Normal file
39
apps/demos/models.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from core.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class DemoEnvironment(BaseModel):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
ACTIVE = "active", "Active"
|
||||||
|
EXPIRED = "expired", "Expired"
|
||||||
|
CLEANED = "cleaned", "Cleaned"
|
||||||
|
|
||||||
|
owner_user = models.OneToOneField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="demo_environment",
|
||||||
|
)
|
||||||
|
workspace = models.OneToOneField(
|
||||||
|
"workspaces.Workspace",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="demo_environment",
|
||||||
|
)
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE)
|
||||||
|
seed_version = models.CharField(max_length=32, default="v1")
|
||||||
|
cleaned_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
cleanup_error = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "demo_environment"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["status", "expires_at"], name="demo_status_expires_idx"),
|
||||||
|
models.Index(fields=["owner_user"], name="demo_owner_user_idx"),
|
||||||
|
models.Index(fields=["workspace"], name="demo_workspace_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Demo {self.workspace_id} for {self.owner_user_id}"
|
||||||
273
apps/demos/services.py
Normal file
273
apps/demos/services.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
|
from apps.clients.models import Client
|
||||||
|
from apps.demos.models import DemoEnvironment
|
||||||
|
from apps.notifications.services import RedisNotificationStore
|
||||||
|
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
|
||||||
|
from apps.reports.models import ReportExportJob
|
||||||
|
from apps.tags.models import Tag
|
||||||
|
from apps.time_entries.models import TimeEntry
|
||||||
|
from apps.users.services.auth import get_tokens_for_user
|
||||||
|
from apps.workspaces.models import HourlyRateHistory, PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
DEMO_SEED_VERSION = "v1"
|
||||||
|
DEMO_RATE_CURRENCY = "IRT"
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_mobile(prefix: str) -> str:
|
||||||
|
for _ in range(50):
|
||||||
|
mobile = f"09{prefix}{''.join(random.choices(string.digits, k=7))}"
|
||||||
|
if not User.all_objects.filter(mobile=mobile).exists():
|
||||||
|
return mobile
|
||||||
|
raise ValidationError({"detail": "Could not allocate a unique demo mobile number."})
|
||||||
|
|
||||||
|
|
||||||
|
def _create_demo_user(*, prefix: str, first_name: str, last_name: str, expires_at):
|
||||||
|
mobile = _unique_mobile(prefix)
|
||||||
|
user = User.objects.create_user(
|
||||||
|
mobile=mobile,
|
||||||
|
password=None,
|
||||||
|
email=f"demo-{mobile}@demo.qlockify.local",
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
is_demo=True,
|
||||||
|
demo_expires_at=expires_at,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_price_units() -> None:
|
||||||
|
PriceUnit.get_or_restore(
|
||||||
|
code="IRT",
|
||||||
|
defaults={"name": "Iranian Toman", "local_name": "تومان", "symbol": "تومان", "is_active": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_workspace_rate(*, workspace, user, amount: str, effective_from):
|
||||||
|
rate = WorkspaceUserRate.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
hourly_rate=Decimal(amount),
|
||||||
|
currency=DEMO_RATE_CURRENCY,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
HourlyRateHistory.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
scope=HourlyRateHistory.Scope.WORKSPACE,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
|
def _create_project_rate(*, project, user, amount: str, effective_from):
|
||||||
|
rate = ProjectUserRate.objects.create(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
hourly_rate=Decimal(amount),
|
||||||
|
currency=DEMO_RATE_CURRENCY,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
HourlyRateHistory.objects.create(
|
||||||
|
workspace=project.workspace,
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
scope=HourlyRateHistory.Scope.PROJECT,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
|
def _create_entry(*, workspace, user, project, tags, days_ago: int, hour: int, duration_hours: float, description: str, billable: bool):
|
||||||
|
start_time = timezone.now().replace(hour=hour, minute=0, second=0, microsecond=0) - timedelta(days=days_ago)
|
||||||
|
end_time = start_time + timedelta(hours=duration_hours)
|
||||||
|
rate = None
|
||||||
|
currency = DEMO_RATE_CURRENCY
|
||||||
|
if billable and project:
|
||||||
|
rate = (
|
||||||
|
ProjectUserRate.objects.filter(project=project, user=user, is_deleted=False, is_active=True)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not rate:
|
||||||
|
rate = (
|
||||||
|
WorkspaceUserRate.objects.filter(workspace=workspace, user=user, is_deleted=False)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if rate:
|
||||||
|
currency = rate.currency
|
||||||
|
|
||||||
|
entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
project=project,
|
||||||
|
description=description,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
duration=end_time - start_time,
|
||||||
|
is_billable=billable,
|
||||||
|
hourly_rate=rate.hourly_rate if rate else None,
|
||||||
|
currency=currency,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
entry.tags.set(tags)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def create_demo_environment():
|
||||||
|
if not getattr(settings, "DEMO_ENABLED", True):
|
||||||
|
raise ValidationError({"detail": "Demo environments are currently disabled."})
|
||||||
|
|
||||||
|
_ensure_price_units()
|
||||||
|
expires_at = timezone.now() + timedelta(hours=settings.DEMO_ENVIRONMENT_TTL_HOURS)
|
||||||
|
owner = _create_demo_user(prefix="70", first_name="Demo", last_name="Owner", expires_at=expires_at)
|
||||||
|
admin = _create_demo_user(prefix="71", first_name="Nika", last_name="Admin", expires_at=expires_at)
|
||||||
|
member = _create_demo_user(prefix="72", first_name="Arman", last_name="Member", expires_at=expires_at)
|
||||||
|
guest = _create_demo_user(prefix="73", first_name="Sara", last_name="Guest", expires_at=expires_at)
|
||||||
|
|
||||||
|
workspace = Workspace.objects.create(
|
||||||
|
name="Qlockify Demo Workspace",
|
||||||
|
description="A temporary sandbox workspace with seeded data for exploring Qlockify.",
|
||||||
|
owner=owner,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
WorkspaceMembership.objects.bulk_create(
|
||||||
|
[
|
||||||
|
WorkspaceMembership(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True),
|
||||||
|
WorkspaceMembership(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True),
|
||||||
|
WorkspaceMembership(workspace=workspace, user=guest, role=WorkspaceMembership.Role.GUEST, is_active=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
for user, amount in ((owner, "750000"), (admin, "650000"), (member, "520000"), (guest, "350000")):
|
||||||
|
_create_workspace_rate(workspace=workspace, user=user, amount=amount, effective_from=now - timedelta(days=60))
|
||||||
|
|
||||||
|
college = Client.objects.create(workspace=workspace, name="Kanoon College", notes="Education client", is_active=True)
|
||||||
|
studio = Client.objects.create(workspace=workspace, name="Nova Studio", notes="Design and product client", is_active=True)
|
||||||
|
internal = Client.objects.create(workspace=workspace, name="Internal Ops", notes="Non-client internal work", is_active=True)
|
||||||
|
|
||||||
|
projects = {
|
||||||
|
"portal": Project.objects.create(workspace=workspace, client=college, name="Student Portal", color="#0891b2", is_active=True),
|
||||||
|
"bootcamp": Project.objects.create(workspace=workspace, client=college, name="Bootcamp Analytics", color="#14b8a6", is_active=True),
|
||||||
|
"brand": Project.objects.create(workspace=workspace, client=studio, name="Brand Refresh", color="#f97316", is_active=True),
|
||||||
|
"ops": Project.objects.create(workspace=workspace, client=internal, name="Operations Automation", color="#6366f1", is_active=True),
|
||||||
|
"archive": Project.objects.create(workspace=workspace, client=studio, name="Archived Campaign", color="#94a3b8", is_archived=True, is_active=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
"design": Tag.objects.create(workspace=workspace, name="Design", color="#f97316", is_active=True),
|
||||||
|
"backend": Tag.objects.create(workspace=workspace, name="Backend", color="#0ea5e9", is_active=True),
|
||||||
|
"meeting": Tag.objects.create(workspace=workspace, name="Meeting", color="#8b5cf6", is_active=True),
|
||||||
|
"qa": Tag.objects.create(workspace=workspace, name="QA", color="#22c55e", is_active=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
for user in (member, guest):
|
||||||
|
for project in (projects["portal"], projects["bootcamp"], projects["brand"]):
|
||||||
|
ProjectAccess.objects.create(project=project, user=user, is_active=True)
|
||||||
|
|
||||||
|
_create_project_rate(project=projects["brand"], user=owner, amount="950000", effective_from=now - timedelta(days=30))
|
||||||
|
_create_project_rate(project=projects["portal"], user=member, amount="610000", effective_from=now - timedelta(days=20))
|
||||||
|
_create_project_rate(project=projects["bootcamp"], user=guest, amount="420000", effective_from=now - timedelta(days=15))
|
||||||
|
|
||||||
|
entry_templates = [
|
||||||
|
(owner, projects["brand"], [tags["design"]], 1, 9, 2.5, "Review landing page motion", True),
|
||||||
|
(owner, projects["ops"], [tags["backend"], tags["qa"]], 2, 10, 3.0, "Improve export pipeline", True),
|
||||||
|
(owner, None, [tags["meeting"]], 3, 13, 1.0, "Weekly planning", False),
|
||||||
|
(admin, projects["portal"], [tags["backend"]], 1, 8, 4.0, "API access checks", True),
|
||||||
|
(admin, projects["bootcamp"], [tags["qa"]], 4, 11, 2.0, "Report QA pass", True),
|
||||||
|
(member, projects["portal"], [tags["backend"], tags["qa"]], 2, 9, 5.0, "Timesheet improvements", True),
|
||||||
|
(member, projects["brand"], [tags["design"]], 6, 14, 2.5, "Design polish", True),
|
||||||
|
(guest, projects["bootcamp"], [tags["meeting"]], 3, 10, 1.5, "Client sync", True),
|
||||||
|
(guest, None, [], 5, 15, 1.0, "Uncategorized admin work", False),
|
||||||
|
]
|
||||||
|
for entry in entry_templates:
|
||||||
|
_create_entry(workspace=workspace, user=entry[0], project=entry[1], tags=entry[2], days_ago=entry[3], hour=entry[4], duration_hours=entry[5], description=entry[6], billable=entry[7])
|
||||||
|
|
||||||
|
environment = DemoEnvironment.objects.create(
|
||||||
|
owner_user=owner,
|
||||||
|
workspace=workspace,
|
||||||
|
expires_at=expires_at,
|
||||||
|
seed_version=DEMO_SEED_VERSION,
|
||||||
|
status=DemoEnvironment.Status.ACTIVE,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
tokens = get_tokens_for_user(owner)
|
||||||
|
return {
|
||||||
|
**tokens,
|
||||||
|
"workspace_id": str(workspace.id),
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
|
"demo_environment_id": str(environment.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_demo_environment(environment: DemoEnvironment) -> bool:
|
||||||
|
workspace = environment.workspace
|
||||||
|
users = list(
|
||||||
|
User.all_objects.filter(
|
||||||
|
is_demo=True,
|
||||||
|
workspace_memberships__workspace=workspace,
|
||||||
|
).distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
for job in ReportExportJob.all_objects.filter(workspace=workspace):
|
||||||
|
if job.file:
|
||||||
|
job.file.delete(save=False)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
RedisNotificationStore.clear_user(str(user.id))
|
||||||
|
|
||||||
|
workspace.hard_delete()
|
||||||
|
for user in users:
|
||||||
|
user.hard_delete()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired_demo_environments(*, batch_size: int | None = None) -> int:
|
||||||
|
batch_size = batch_size or settings.DEMO_CLEANUP_BATCH_SIZE
|
||||||
|
expired = list(
|
||||||
|
DemoEnvironment.objects.filter(
|
||||||
|
status=DemoEnvironment.Status.ACTIVE,
|
||||||
|
expires_at__lte=timezone.now(),
|
||||||
|
)
|
||||||
|
.select_related("workspace", "owner_user")
|
||||||
|
.order_by("expires_at")[:batch_size]
|
||||||
|
)
|
||||||
|
cleaned = 0
|
||||||
|
for environment in expired:
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
cleanup_demo_environment(environment)
|
||||||
|
cleaned += 1
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
DemoEnvironment.all_objects.filter(id=environment.id).update(
|
||||||
|
status=DemoEnvironment.Status.EXPIRED,
|
||||||
|
cleanup_error=str(exc)[:2000],
|
||||||
|
updated_at=timezone.now(),
|
||||||
|
)
|
||||||
|
return cleaned
|
||||||
8
apps/demos/tasks.py
Normal file
8
apps/demos/tasks.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
from apps.demos.services import cleanup_expired_demo_environments
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="demos.cleanup_expired_environments")
|
||||||
|
def cleanup_expired_demo_environments_task():
|
||||||
|
return cleanup_expired_demo_environments()
|
||||||
1
apps/demos/tests/__init__.py
Normal file
1
apps/demos/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
77
apps/demos/tests/test_demo_api.py
Normal file
77
apps/demos/tests/test_demo_api.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from apps.clients.models import Client
|
||||||
|
from apps.demos.models import DemoEnvironment
|
||||||
|
from apps.demos.services import cleanup_expired_demo_environments
|
||||||
|
from apps.projects.models import Project, ProjectAccess
|
||||||
|
from apps.tags.models import Tag
|
||||||
|
from apps.time_entries.models import TimeEntry
|
||||||
|
from apps.workspaces.models import WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
DEMO_START_URL = "/api/demo/start/"
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(DEMO_ENABLED=True, DEMO_ENVIRONMENT_TTL_HOURS=24, DEMO_CLEANUP_BATCH_SIZE=100)
|
||||||
|
class DemoStartApiTests(APITestCase):
|
||||||
|
def test_demo_start_creates_isolated_seeded_environment(self):
|
||||||
|
response = self.client.post(DEMO_START_URL)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertIn("access", response.data)
|
||||||
|
self.assertIn("refresh", response.data)
|
||||||
|
self.assertEqual(DemoEnvironment.objects.count(), 1)
|
||||||
|
|
||||||
|
environment = DemoEnvironment.objects.select_related("owner_user", "workspace").get()
|
||||||
|
self.assertTrue(environment.owner_user.is_demo)
|
||||||
|
self.assertEqual(environment.owner_user.demo_expires_at, environment.expires_at)
|
||||||
|
self.assertGreaterEqual(WorkspaceMembership.objects.filter(workspace=environment.workspace).count(), 4)
|
||||||
|
self.assertGreaterEqual(Client.objects.filter(workspace=environment.workspace).count(), 3)
|
||||||
|
self.assertGreaterEqual(Project.objects.filter(workspace=environment.workspace).count(), 5)
|
||||||
|
self.assertGreaterEqual(Tag.objects.filter(workspace=environment.workspace).count(), 4)
|
||||||
|
self.assertGreaterEqual(TimeEntry.objects.filter(workspace=environment.workspace).count(), 8)
|
||||||
|
self.assertGreaterEqual(WorkspaceUserRate.objects.filter(workspace=environment.workspace).count(), 4)
|
||||||
|
self.assertGreaterEqual(ProjectAccess.objects.filter(project__workspace=environment.workspace).count(), 1)
|
||||||
|
|
||||||
|
def test_two_demo_starts_do_not_share_workspace_data(self):
|
||||||
|
first = self.client.post(DEMO_START_URL)
|
||||||
|
second = self.client.post(DEMO_START_URL)
|
||||||
|
|
||||||
|
self.assertEqual(first.status_code, 201)
|
||||||
|
self.assertEqual(second.status_code, 201)
|
||||||
|
environments = list(DemoEnvironment.objects.order_by("created_at"))
|
||||||
|
self.assertEqual(len(environments), 2)
|
||||||
|
self.assertNotEqual(environments[0].workspace_id, environments[1].workspace_id)
|
||||||
|
self.assertNotEqual(environments[0].owner_user_id, environments[1].owner_user_id)
|
||||||
|
|
||||||
|
def test_demo_user_cannot_search_external_users_or_send_otp(self):
|
||||||
|
self.client.post(DEMO_START_URL)
|
||||||
|
environment = DemoEnvironment.objects.select_related("owner_user").get()
|
||||||
|
real_user = User.objects.create_user(mobile="09111111111", password="Testpass123!")
|
||||||
|
self.client.force_authenticate(environment.owner_user)
|
||||||
|
|
||||||
|
search_response = self.client.get(f"/api/users/search/?mobile={real_user.mobile}")
|
||||||
|
self.assertEqual(search_response.status_code, 403)
|
||||||
|
|
||||||
|
otp_response = self.client.post(
|
||||||
|
"/api/users/otp/send/",
|
||||||
|
{"mobile": environment.owner_user.mobile, "mode": "login"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(otp_response.status_code, 400)
|
||||||
|
|
||||||
|
def test_cleanup_deletes_expired_demo_and_keeps_real_users(self):
|
||||||
|
self.client.post(DEMO_START_URL)
|
||||||
|
environment = DemoEnvironment.objects.select_related("workspace").get()
|
||||||
|
real_user = User.objects.create_user(mobile="09122222222", password="Testpass123!")
|
||||||
|
DemoEnvironment.objects.filter(id=environment.id).update(expires_at=timezone.now() - timezone.timedelta(minutes=1))
|
||||||
|
|
||||||
|
cleaned = cleanup_expired_demo_environments()
|
||||||
|
|
||||||
|
self.assertEqual(cleaned, 1)
|
||||||
|
self.assertFalse(DemoEnvironment.all_objects.filter(id=environment.id).exists())
|
||||||
|
self.assertFalse(TimeEntry.all_objects.filter(workspace_id=environment.workspace_id).exists())
|
||||||
|
self.assertTrue(User.objects.filter(id=real_user.id).exists())
|
||||||
@@ -4,7 +4,6 @@ SECTION_WORKSPACE = "workspace"
|
|||||||
SECTION_WORKSPACE_MEMBERS = "workspace_members"
|
SECTION_WORKSPACE_MEMBERS = "workspace_members"
|
||||||
SECTION_CLIENTS = "clients"
|
SECTION_CLIENTS = "clients"
|
||||||
SECTION_PROJECTS = "projects"
|
SECTION_PROJECTS = "projects"
|
||||||
SECTION_PROJECT_MEMBERS = "project_members"
|
|
||||||
SECTION_TAGS = "tags"
|
SECTION_TAGS = "tags"
|
||||||
SECTION_TIME_ENTRIES = "time_entries"
|
SECTION_TIME_ENTRIES = "time_entries"
|
||||||
SECTION_RATES = "rates"
|
SECTION_RATES = "rates"
|
||||||
@@ -15,7 +14,6 @@ LOG_SECTIONS = (
|
|||||||
SECTION_WORKSPACE_MEMBERS,
|
SECTION_WORKSPACE_MEMBERS,
|
||||||
SECTION_CLIENTS,
|
SECTION_CLIENTS,
|
||||||
SECTION_PROJECTS,
|
SECTION_PROJECTS,
|
||||||
SECTION_PROJECT_MEMBERS,
|
|
||||||
SECTION_TAGS,
|
SECTION_TAGS,
|
||||||
SECTION_TIME_ENTRIES,
|
SECTION_TIME_ENTRIES,
|
||||||
SECTION_RATES,
|
SECTION_RATES,
|
||||||
@@ -48,11 +46,9 @@ SECTION_BY_MODEL_LABEL = {
|
|||||||
"workspaces.workspaceuserrate": SECTION_RATES,
|
"workspaces.workspaceuserrate": SECTION_RATES,
|
||||||
"clients.client": SECTION_CLIENTS,
|
"clients.client": SECTION_CLIENTS,
|
||||||
"projects.project": SECTION_PROJECTS,
|
"projects.project": SECTION_PROJECTS,
|
||||||
"projects.projectmembership": SECTION_PROJECT_MEMBERS,
|
|
||||||
"tags.tag": SECTION_TAGS,
|
"tags.tag": SECTION_TAGS,
|
||||||
"time_entries.timeentry": SECTION_TIME_ENTRIES,
|
"time_entries.timeentry": SECTION_TIME_ENTRIES,
|
||||||
"reports.reportexportjob": SECTION_REPORT_EXPORTS,
|
"reports.reportexportjob": SECTION_REPORT_EXPORTS,
|
||||||
}
|
}
|
||||||
|
|
||||||
TRACKED_MODEL_LABELS = tuple(SECTION_BY_MODEL_LABEL.keys())
|
TRACKED_MODEL_LABELS = tuple(SECTION_BY_MODEL_LABEL.keys())
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,40 @@
|
|||||||
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
|
||||||
|
def _user(index):
|
||||||
return User.objects.create_user(
|
return User.objects.create_user(
|
||||||
mobile=f"093355500{index:02d}",
|
mobile=f"093355500{index:02d}",
|
||||||
password="secret123",
|
password="secret123",
|
||||||
@@ -25,157 +42,120 @@ def _user(index: int) -> User:
|
|||||||
last_name="User",
|
last_name="User",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
@pytest.fixture()
|
def _auth_headers(user):
|
||||||
def owner(db):
|
|
||||||
return _user(1)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def admin(db):
|
|
||||||
return _user(2)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def member(db):
|
|
||||||
return _user(3)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def outsider(db):
|
|
||||||
return _user(4)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def workspace(owner, admin, member):
|
|
||||||
workspace = Workspace.objects.create(name="Logs WS", description="", owner=owner)
|
|
||||||
WorkspaceMembership.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=admin,
|
|
||||||
role=WorkspaceMembership.Role.ADMIN,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
WorkspaceMembership.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=member,
|
|
||||||
role=WorkspaceMembership.Role.MEMBER,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
return workspace
|
|
||||||
|
|
||||||
|
|
||||||
def _auth_headers(user: User) -> dict:
|
|
||||||
token = str(AccessToken.for_user(user))
|
token = str(AccessToken.for_user(user))
|
||||||
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
||||||
|
|
||||||
|
def _create_tag(self, user, *, name="Audit Tag"):
|
||||||
def _create_tag(client: APIClient, user: User, workspace: Workspace, *, name="Audit Tag"):
|
return self.client.post(
|
||||||
return client.post(
|
|
||||||
"/api/tags/",
|
"/api/tags/",
|
||||||
{"workspace_id": str(workspace.id), "name": name, "color": "#123456"},
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": name,
|
||||||
|
"color": "#123456",
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
**_auth_headers(user),
|
**self._auth_headers(user),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_owner_and_admin_can_list_workspace_logs(self):
|
||||||
|
create_response = self._create_tag(self.owner)
|
||||||
|
self.assertEqual(create_response.status_code, 201)
|
||||||
|
|
||||||
@pytest.mark.django_db
|
owner_response = self.client.get(
|
||||||
def test_owner_and_admin_can_list_workspace_logs(api_client, owner, admin, workspace):
|
f"/api/logs/?workspace={self.workspace.id}",
|
||||||
create_response = _create_tag(api_client, owner, workspace)
|
**self._auth_headers(self.owner),
|
||||||
assert create_response.status_code == 201
|
|
||||||
|
|
||||||
owner_response = api_client.get(
|
|
||||||
f"/api/logs/?workspace={workspace.id}",
|
|
||||||
**_auth_headers(owner),
|
|
||||||
)
|
)
|
||||||
admin_response = api_client.get(
|
admin_response = self.client.get(
|
||||||
f"/api/logs/?workspace={workspace.id}",
|
f"/api/logs/?workspace={self.workspace.id}",
|
||||||
**_auth_headers(admin),
|
**self._auth_headers(self.admin),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert owner_response.status_code == 200
|
self.assertEqual(owner_response.status_code, 200)
|
||||||
assert admin_response.status_code == 200
|
self.assertEqual(admin_response.status_code, 200)
|
||||||
assert owner_response.data["items"][0]["section"] == "tags"
|
self.assertEqual(owner_response.data["items"][0]["section"], "tags")
|
||||||
|
|
||||||
|
def test_member_and_non_member_cannot_list_workspace_logs(self):
|
||||||
|
self._create_tag(self.owner)
|
||||||
|
|
||||||
@pytest.mark.django_db
|
member_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}",
|
||||||
_create_tag(api_client, owner, workspace)
|
**self._auth_headers(self.member),
|
||||||
|
|
||||||
member_response = api_client.get(
|
|
||||||
f"/api/logs/?workspace={workspace.id}",
|
|
||||||
**_auth_headers(member),
|
|
||||||
)
|
)
|
||||||
outsider_response = api_client.get(
|
outsider_response = self.client.get(
|
||||||
f"/api/logs/?workspace={workspace.id}",
|
f"/api/logs/?workspace={self.workspace.id}",
|
||||||
**_auth_headers(outsider),
|
**self._auth_headers(self.outsider),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert member_response.status_code == 403
|
self.assertEqual(member_response.status_code, 403)
|
||||||
assert outsider_response.status_code == 403
|
self.assertEqual(outsider_response.status_code, 403)
|
||||||
|
|
||||||
|
def test_jwt_authenticated_writes_capture_actor_and_workspace_metadata(self):
|
||||||
|
response = self._create_tag(self.owner, name="JWT Tag")
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
||||||
@pytest.mark.django_db
|
log_entry = LogEntry.objects.filter(content_type__app_label="tags").latest(
|
||||||
def test_jwt_authenticated_writes_capture_actor_and_workspace_metadata(api_client, owner, workspace):
|
"timestamp"
|
||||||
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
|
self.assertEqual(log_entry.actor_id, self.owner.id)
|
||||||
assert list_response.data["items"]
|
self.assertEqual(log_entry.additional_data["workspace_id"], str(self.workspace.id))
|
||||||
|
self.assertEqual(log_entry.additional_data["section"], "tags")
|
||||||
|
|
||||||
|
def test_logs_support_section_filter_and_detail(self):
|
||||||
|
tag_response = self._create_tag(self.owner, name="Filtered Tag")
|
||||||
|
self.assertEqual(tag_response.status_code, 201)
|
||||||
|
|
||||||
|
list_response = self.client.get(
|
||||||
|
f"/api/logs/?workspace={self.workspace.id}§ion=tags",
|
||||||
|
**self._auth_headers(self.owner),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(list_response.status_code, 200)
|
||||||
|
self.assertTrue(list_response.data["items"])
|
||||||
log_id = list_response.data["items"][0]["id"]
|
log_id = list_response.data["items"][0]["id"]
|
||||||
|
|
||||||
detail_response = api_client.get(
|
detail_response = self.client.get(
|
||||||
f"/api/logs/{log_id}/",
|
f"/api/logs/{log_id}/",
|
||||||
**_auth_headers(owner),
|
**self._auth_headers(self.owner),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert detail_response.status_code == 200
|
self.assertEqual(detail_response.status_code, 200)
|
||||||
assert detail_response.data["target"]["name"] == "Filtered Tag"
|
self.assertEqual(detail_response.data["target"]["name"], "Filtered Tag")
|
||||||
assert detail_response.data["changes"]
|
self.assertTrue(detail_response.data["changes"])
|
||||||
|
|
||||||
|
def test_soft_delete_and_actorless_background_logs_are_filtered(self):
|
||||||
@pytest.mark.django_db
|
create_response = self._create_tag(self.owner, name="Delete Me")
|
||||||
def test_soft_delete_and_actorless_background_logs_are_filtered(api_client, owner, workspace):
|
self.assertEqual(create_response.status_code, 201)
|
||||||
create_response = _create_tag(api_client, owner, workspace, name="Delete Me")
|
|
||||||
assert create_response.status_code == 201
|
|
||||||
tag_id = create_response.data["id"]
|
tag_id = create_response.data["id"]
|
||||||
|
|
||||||
delete_response = api_client.delete(
|
delete_response = self.client.delete(
|
||||||
f"/api/tags/{tag_id}/",
|
f"/api/tags/{tag_id}/",
|
||||||
**_auth_headers(owner),
|
**self._auth_headers(self.owner),
|
||||||
)
|
)
|
||||||
assert delete_response.status_code == 204
|
self.assertEqual(delete_response.status_code, 204)
|
||||||
|
|
||||||
ReportExportJob.objects.create(
|
ReportExportJob.objects.create(
|
||||||
requesting_user=owner,
|
requesting_user=self.owner,
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
export_type=ReportExportJob.ExportType.PDF,
|
export_type=ReportExportJob.ExportType.PDF,
|
||||||
filters={"workspace": str(workspace.id)},
|
filters={"workspace": str(self.workspace.id)},
|
||||||
status=ReportExportJob.Status.PENDING,
|
status=ReportExportJob.Status.PENDING,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = api_client.get(
|
response = self.client.get(
|
||||||
f"/api/logs/?workspace={workspace.id}&event=delete",
|
f"/api/logs/?workspace={self.workspace.id}&event=delete",
|
||||||
**_auth_headers(owner),
|
**self._auth_headers(self.owner),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert any(item["event"] == "delete" and item["section"] == "tags" for item in response.data["items"])
|
self.assertTrue(
|
||||||
assert all(item["section"] != "report_exports" for item in response.data["items"])
|
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,8 +1,4 @@
|
|||||||
from apps.notifications.services.membership_events import (
|
from apps.notifications.services.membership_events import (
|
||||||
notify_project_membership_added,
|
|
||||||
notify_project_membership_deactivated,
|
|
||||||
notify_project_membership_removed,
|
|
||||||
notify_project_membership_role_changed,
|
|
||||||
notify_workspace_membership_added,
|
notify_workspace_membership_added,
|
||||||
notify_workspace_membership_deactivated,
|
notify_workspace_membership_deactivated,
|
||||||
notify_workspace_membership_removed,
|
notify_workspace_membership_removed,
|
||||||
@@ -16,8 +12,4 @@ __all__ = [
|
|||||||
"notify_workspace_membership_role_changed",
|
"notify_workspace_membership_role_changed",
|
||||||
"notify_workspace_membership_deactivated",
|
"notify_workspace_membership_deactivated",
|
||||||
"notify_workspace_membership_removed",
|
"notify_workspace_membership_removed",
|
||||||
"notify_project_membership_added",
|
|
||||||
"notify_project_membership_role_changed",
|
|
||||||
"notify_project_membership_deactivated",
|
|
||||||
"notify_project_membership_removed",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ def _workspace_action_url(workspace) -> str:
|
|||||||
return f"/workspaces/{workspace.id}"
|
return f"/workspaces/{workspace.id}"
|
||||||
|
|
||||||
|
|
||||||
def _project_action_url(project) -> str:
|
|
||||||
return "/projects"
|
|
||||||
|
|
||||||
|
|
||||||
def notify_workspace_membership_added(*, actor, recipient, workspace, role: str) -> None:
|
def notify_workspace_membership_added(*, actor, recipient, workspace, role: str) -> None:
|
||||||
if _should_skip(actor, recipient):
|
if _should_skip(actor, recipient):
|
||||||
return
|
return
|
||||||
@@ -148,124 +144,3 @@ def notify_workspace_membership_removed(*, actor, recipient, workspace, role: st
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def notify_project_membership_added(*, actor, recipient, project, role: str) -> None:
|
|
||||||
if _should_skip(actor, recipient):
|
|
||||||
return
|
|
||||||
|
|
||||||
actor_display = _actor_name(actor)
|
|
||||||
role_label = _role_label(recipient.project_memberships.model.Role, role)
|
|
||||||
_notify_user(
|
|
||||||
recipient,
|
|
||||||
{
|
|
||||||
"type": "project_membership_added",
|
|
||||||
"title": "Added to project",
|
|
||||||
"message": f"{actor_display} added you to {project.name} as {role_label}.",
|
|
||||||
"level": "info",
|
|
||||||
"action_url": _project_action_url(project),
|
|
||||||
"entity_type": "project",
|
|
||||||
"entity_id": str(project.id),
|
|
||||||
"meta": {
|
|
||||||
"workspace_id": str(project.workspace_id),
|
|
||||||
"workspace_name": project.workspace.name,
|
|
||||||
"project_id": str(project.id),
|
|
||||||
"project_name": project.name,
|
|
||||||
"actor_id": str(actor.id),
|
|
||||||
"actor_name": actor_display,
|
|
||||||
"new_role": role,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def notify_project_membership_role_changed(
|
|
||||||
*, actor, recipient, project, previous_role: str, new_role: str
|
|
||||||
) -> None:
|
|
||||||
if _should_skip(actor, recipient) or previous_role == new_role:
|
|
||||||
return
|
|
||||||
|
|
||||||
actor_display = _actor_name(actor)
|
|
||||||
previous_role_label = _role_label(recipient.project_memberships.model.Role, previous_role)
|
|
||||||
new_role_label = _role_label(recipient.project_memberships.model.Role, new_role)
|
|
||||||
_notify_user(
|
|
||||||
recipient,
|
|
||||||
{
|
|
||||||
"type": "project_membership_role_changed",
|
|
||||||
"title": "Project role changed",
|
|
||||||
"message": (
|
|
||||||
f"{actor_display} changed your role in {project.name} "
|
|
||||||
f"from {previous_role_label} to {new_role_label}."
|
|
||||||
),
|
|
||||||
"level": "info",
|
|
||||||
"action_url": _project_action_url(project),
|
|
||||||
"entity_type": "project",
|
|
||||||
"entity_id": str(project.id),
|
|
||||||
"meta": {
|
|
||||||
"workspace_id": str(project.workspace_id),
|
|
||||||
"workspace_name": project.workspace.name,
|
|
||||||
"project_id": str(project.id),
|
|
||||||
"project_name": project.name,
|
|
||||||
"actor_id": str(actor.id),
|
|
||||||
"actor_name": actor_display,
|
|
||||||
"previous_role": previous_role,
|
|
||||||
"new_role": new_role,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def notify_project_membership_deactivated(*, actor, recipient, project, role: str) -> None:
|
|
||||||
if _should_skip(actor, recipient):
|
|
||||||
return
|
|
||||||
|
|
||||||
actor_display = _actor_name(actor)
|
|
||||||
_notify_user(
|
|
||||||
recipient,
|
|
||||||
{
|
|
||||||
"type": "project_membership_deactivated",
|
|
||||||
"title": "Project access deactivated",
|
|
||||||
"message": f"{actor_display} deactivated your access to {project.name}.",
|
|
||||||
"level": "warning",
|
|
||||||
"action_url": _project_action_url(project),
|
|
||||||
"entity_type": "project",
|
|
||||||
"entity_id": str(project.id),
|
|
||||||
"meta": {
|
|
||||||
"workspace_id": str(project.workspace_id),
|
|
||||||
"workspace_name": project.workspace.name,
|
|
||||||
"project_id": str(project.id),
|
|
||||||
"project_name": project.name,
|
|
||||||
"actor_id": str(actor.id),
|
|
||||||
"actor_name": actor_display,
|
|
||||||
"previous_role": role,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def notify_project_membership_removed(*, actor, recipient, project, role: str) -> None:
|
|
||||||
if _should_skip(actor, recipient):
|
|
||||||
return
|
|
||||||
|
|
||||||
actor_display = _actor_name(actor)
|
|
||||||
_notify_user(
|
|
||||||
recipient,
|
|
||||||
{
|
|
||||||
"type": "project_membership_removed",
|
|
||||||
"title": "Removed from project",
|
|
||||||
"message": f"{actor_display} removed you from {project.name}.",
|
|
||||||
"level": "warning",
|
|
||||||
"action_url": _project_action_url(project),
|
|
||||||
"entity_type": "project",
|
|
||||||
"entity_id": str(project.id),
|
|
||||||
"meta": {
|
|
||||||
"workspace_id": str(project.workspace_id),
|
|
||||||
"workspace_name": project.workspace.name,
|
|
||||||
"project_id": str(project.id),
|
|
||||||
"project_name": project.name,
|
|
||||||
"actor_id": str(actor.id),
|
|
||||||
"actor_name": actor_display,
|
|
||||||
"previous_role": role,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -205,6 +205,18 @@ class RedisNotificationStore:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear_user(cls, user_id: str) -> int:
|
||||||
|
ids_key = cls._ids_key(user_id)
|
||||||
|
data_key = cls._data_key(user_id)
|
||||||
|
count = redis_client.zcard(ids_key)
|
||||||
|
pipe = redis_client.pipeline()
|
||||||
|
pipe.delete(ids_key)
|
||||||
|
pipe.delete(data_key)
|
||||||
|
pipe.srem(cls.USERS_KEY, user_id)
|
||||||
|
pipe.execute()
|
||||||
|
return int(count or 0)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def mark_seen(cls, user_id: str, notif_id: str) -> dict | None:
|
def mark_seen(cls, user_id: str, notif_id: str) -> dict | None:
|
||||||
data = cls.get(user_id, notif_id)
|
data = cls.get(user_id, notif_id)
|
||||||
|
|||||||
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,297 +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.projects.models import Project, ProjectMembership
|
|
||||||
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
|
||||||
@pytest.fixture()
|
def _create_user(index):
|
||||||
def api_client():
|
|
||||||
return APIClient()
|
|
||||||
|
|
||||||
|
|
||||||
def _create_user(index: int) -> User:
|
|
||||||
return User.objects.create_user(
|
return User.objects.create_user(
|
||||||
mobile=f"091200000{index:02d}",
|
mobile=f"091200000{index:02d}",
|
||||||
password="secret123",
|
password="secret123",
|
||||||
first_name=f"User{index}",
|
first_name=f"User{index}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.fake_redis = FakeRedis()
|
||||||
|
self.original_redis_client = services.redis_client
|
||||||
|
services.redis_client = self.fake_redis
|
||||||
|
|
||||||
def _notifications_for(user):
|
def tearDown(self):
|
||||||
notifications, _ = RedisNotificationStore.list(
|
services.redis_client = self.original_redis_client
|
||||||
str(user.id),
|
|
||||||
paginate=False,
|
@staticmethod
|
||||||
)
|
def _notifications_for(user):
|
||||||
|
notifications, _ = RedisNotificationStore.list(str(user.id), paginate=False)
|
||||||
return notifications
|
return notifications
|
||||||
|
|
||||||
|
def test_workspace_create_notifies_initial_members_not_owner(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
@pytest.fixture()
|
response = self.client.post(
|
||||||
def owner(db):
|
|
||||||
return _create_user(1)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def member(db):
|
|
||||||
return _create_user(2)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def another_member(db):
|
|
||||||
return _create_user(3)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def third_member(db):
|
|
||||||
return _create_user(4)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def fourth_member(db):
|
|
||||||
return _create_user(5)
|
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_create_notifies_initial_members_not_owner(
|
|
||||||
fake_redis, api_client, owner, member
|
|
||||||
):
|
|
||||||
api_client.force_authenticate(user=owner)
|
|
||||||
|
|
||||||
response = api_client.post(
|
|
||||||
"/api/workspaces/",
|
"/api/workspaces/",
|
||||||
{
|
{
|
||||||
"name": "Ops",
|
"name": "Ops",
|
||||||
"description": "Workspace",
|
"description": "Workspace",
|
||||||
"members": [
|
"members": [
|
||||||
{"user_id": str(member.id), "role": WorkspaceMembership.Role.ADMIN}
|
{
|
||||||
|
"user_id": str(self.member.id),
|
||||||
|
"role": WorkspaceMembership.Role.ADMIN,
|
||||||
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 201
|
self.assertEqual(response.status_code, 201)
|
||||||
owner_notifications = _notifications_for(owner)
|
self.assertEqual(self._notifications_for(self.owner), [])
|
||||||
member_notifications = _notifications_for(member)
|
member_notifications = self._notifications_for(self.member)
|
||||||
|
self.assertEqual(len(member_notifications), 1)
|
||||||
|
self.assertEqual(member_notifications[0]["type"], "workspace_membership_added")
|
||||||
|
self.assertEqual(member_notifications[0]["meta"]["workspace_name"], "Ops")
|
||||||
|
self.assertEqual(
|
||||||
|
member_notifications[0]["meta"]["new_role"],
|
||||||
|
WorkspaceMembership.Role.ADMIN,
|
||||||
|
)
|
||||||
|
|
||||||
assert owner_notifications == []
|
def test_workspace_membership_crud_emits_all_expected_events(self):
|
||||||
assert len(member_notifications) == 1
|
workspace = Workspace.objects.create(name="Design", description="", owner=self.owner)
|
||||||
assert member_notifications[0]["type"] == "workspace_membership_added"
|
self.client.force_authenticate(user=self.owner)
|
||||||
assert member_notifications[0]["meta"]["workspace_name"] == "Ops"
|
|
||||||
assert member_notifications[0]["meta"]["new_role"] == WorkspaceMembership.Role.ADMIN
|
|
||||||
|
|
||||||
|
create_response = self.client.post(
|
||||||
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/",
|
"/api/workspace-memberships/",
|
||||||
{
|
{
|
||||||
"workspace": str(workspace.id),
|
"workspace": str(workspace.id),
|
||||||
"user": str(member.id),
|
"user": str(self.member.id),
|
||||||
"role": WorkspaceMembership.Role.MEMBER,
|
"role": WorkspaceMembership.Role.MEMBER,
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert create_response.status_code == 201
|
self.assertEqual(create_response.status_code, 201)
|
||||||
|
|
||||||
membership_id = create_response.data["id"]
|
membership_id = create_response.data["id"]
|
||||||
notifications = _notifications_for(member)
|
notifications = self._notifications_for(self.member)
|
||||||
assert [item["type"] for item in notifications] == ["workspace_membership_added"]
|
self.assertEqual(
|
||||||
|
[item["type"] for item in notifications],
|
||||||
|
["workspace_membership_added"],
|
||||||
|
)
|
||||||
|
|
||||||
role_response = api_client.patch(
|
role_response = self.client.patch(
|
||||||
f"/api/workspace-memberships/{membership_id}/",
|
f"/api/workspace-memberships/{membership_id}/",
|
||||||
{"role": WorkspaceMembership.Role.ADMIN},
|
{"role": WorkspaceMembership.Role.ADMIN},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert role_response.status_code == 200
|
self.assertEqual(role_response.status_code, 200)
|
||||||
|
|
||||||
deactivate_response = api_client.patch(
|
deactivate_response = self.client.patch(
|
||||||
f"/api/workspace-memberships/{membership_id}/",
|
f"/api/workspace-memberships/{membership_id}/",
|
||||||
{"is_active": False},
|
{"is_active": False},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert deactivate_response.status_code == 200
|
self.assertEqual(deactivate_response.status_code, 200)
|
||||||
|
|
||||||
remove_response = api_client.delete(f"/api/workspace-memberships/{membership_id}/")
|
remove_response = self.client.delete(
|
||||||
assert remove_response.status_code == 204
|
f"/api/workspace-memberships/{membership_id}/"
|
||||||
|
)
|
||||||
|
self.assertEqual(remove_response.status_code, 204)
|
||||||
|
|
||||||
notifications = _notifications_for(member)
|
notifications = self._notifications_for(self.member)
|
||||||
assert [item["type"] for item in notifications] == [
|
self.assertEqual(
|
||||||
|
[item["type"] for item in notifications],
|
||||||
|
[
|
||||||
"workspace_membership_removed",
|
"workspace_membership_removed",
|
||||||
"workspace_membership_deactivated",
|
"workspace_membership_deactivated",
|
||||||
"workspace_membership_role_changed",
|
"workspace_membership_role_changed",
|
||||||
"workspace_membership_added",
|
"workspace_membership_added",
|
||||||
]
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_workspace_membership_update_skips_self_notifications(self):
|
||||||
def test_workspace_membership_update_skips_self_notifications(
|
workspace = Workspace.objects.create(name="Product", description="", owner=self.owner)
|
||||||
fake_redis, api_client, owner
|
|
||||||
):
|
|
||||||
workspace = Workspace.objects.create(name="Product", description="", owner=owner)
|
|
||||||
owner_membership = WorkspaceMembership.objects.get(
|
owner_membership = WorkspaceMembership.objects.get(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
user=owner,
|
user=self.owner,
|
||||||
is_deleted=False,
|
is_deleted=False,
|
||||||
)
|
)
|
||||||
api_client.force_authenticate(user=owner)
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
response = api_client.patch(
|
response = self.client.patch(
|
||||||
f"/api/workspace-memberships/{owner_membership.id}/",
|
f"/api/workspace-memberships/{owner_membership.id}/",
|
||||||
{"role": WorkspaceMembership.Role.OWNER},
|
{"role": WorkspaceMembership.Role.OWNER},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
self.assertEqual(response.status_code, 403)
|
||||||
assert _notifications_for(owner) == []
|
self.assertEqual(self._notifications_for(self.owner), [])
|
||||||
|
|
||||||
|
|
||||||
def test_project_create_notifies_initial_members_not_creator(
|
|
||||||
fake_redis, api_client, owner, member
|
|
||||||
):
|
|
||||||
workspace = Workspace.objects.create(name="Engineering", description="", owner=owner)
|
|
||||||
api_client.force_authenticate(user=owner)
|
|
||||||
|
|
||||||
response = api_client.post(
|
|
||||||
"/api/projects/",
|
|
||||||
{
|
|
||||||
"workspace": str(workspace.id),
|
|
||||||
"name": "API",
|
|
||||||
"description": "",
|
|
||||||
"client": None,
|
|
||||||
"members": [
|
|
||||||
{"user_id": str(member.id), "role": ProjectMembership.Role.MEMBER}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
assert _notifications_for(owner) == []
|
|
||||||
|
|
||||||
notifications = _notifications_for(member)
|
|
||||||
assert len(notifications) == 1
|
|
||||||
assert notifications[0]["type"] == "project_membership_added"
|
|
||||||
assert notifications[0]["meta"]["project_name"] == "API"
|
|
||||||
|
|
||||||
|
|
||||||
def test_project_update_full_sync_notifies_only_real_deltas(
|
|
||||||
fake_redis, api_client, owner, member, another_member, third_member, fourth_member
|
|
||||||
):
|
|
||||||
workspace = Workspace.objects.create(name="Build", description="", owner=owner)
|
|
||||||
project = Project.objects.create(workspace=workspace, name="Platform", description="")
|
|
||||||
ProjectMembership.objects.create(
|
|
||||||
project=project,
|
|
||||||
user=owner,
|
|
||||||
role=ProjectMembership.Role.MANAGER,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
ProjectMembership.objects.create(
|
|
||||||
project=project,
|
|
||||||
user=member,
|
|
||||||
role=ProjectMembership.Role.MEMBER,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
ProjectMembership.objects.create(
|
|
||||||
project=project,
|
|
||||||
user=another_member,
|
|
||||||
role=ProjectMembership.Role.MEMBER,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
ProjectMembership.objects.create(
|
|
||||||
project=project,
|
|
||||||
user=third_member,
|
|
||||||
role=ProjectMembership.Role.MEMBER,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
api_client.force_authenticate(user=owner)
|
|
||||||
response = api_client.patch(
|
|
||||||
f"/api/projects/{project.id}/",
|
|
||||||
{
|
|
||||||
"client": None,
|
|
||||||
"members": [
|
|
||||||
{"user_id": str(member.id), "role": ProjectMembership.Role.MANAGER},
|
|
||||||
{"user_id": str(third_member.id), "role": ProjectMembership.Role.MEMBER},
|
|
||||||
{"user_id": str(fourth_member.id), "role": ProjectMembership.Role.MEMBER},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert [item["type"] for item in _notifications_for(member)] == [
|
|
||||||
"project_membership_role_changed"
|
|
||||||
]
|
|
||||||
assert [item["type"] for item in _notifications_for(another_member)] == [
|
|
||||||
"project_membership_deactivated"
|
|
||||||
]
|
|
||||||
assert _notifications_for(third_member) == []
|
|
||||||
assert [item["type"] for item in _notifications_for(fourth_member)] == [
|
|
||||||
"project_membership_added"
|
|
||||||
]
|
|
||||||
assert _notifications_for(owner) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_project_membership_crud_emits_add_role_change_deactivate_and_remove(
|
|
||||||
fake_redis, api_client, owner, member
|
|
||||||
):
|
|
||||||
workspace = Workspace.objects.create(name="Data", description="", owner=owner)
|
|
||||||
project = Project.objects.create(workspace=workspace, name="Warehouse", description="")
|
|
||||||
ProjectMembership.objects.create(
|
|
||||||
project=project,
|
|
||||||
user=owner,
|
|
||||||
role=ProjectMembership.Role.MANAGER,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
api_client.force_authenticate(user=owner)
|
|
||||||
|
|
||||||
create_response = api_client.post(
|
|
||||||
"/api/memberships/",
|
|
||||||
{
|
|
||||||
"project_id": str(project.id),
|
|
||||||
"user_id": str(member.id),
|
|
||||||
"role": ProjectMembership.Role.MEMBER,
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert create_response.status_code == 201
|
|
||||||
membership_id = create_response.data["id"]
|
|
||||||
|
|
||||||
role_response = api_client.patch(
|
|
||||||
f"/api/memberships/{membership_id}/",
|
|
||||||
{"role": ProjectMembership.Role.MANAGER},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert role_response.status_code == 200
|
|
||||||
|
|
||||||
deactivate_response = api_client.patch(
|
|
||||||
f"/api/memberships/{membership_id}/",
|
|
||||||
{"is_active": False},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert deactivate_response.status_code == 200
|
|
||||||
|
|
||||||
remove_response = api_client.delete(f"/api/memberships/{membership_id}/")
|
|
||||||
assert remove_response.status_code == 204
|
|
||||||
|
|
||||||
notifications = _notifications_for(member)
|
|
||||||
assert [item["type"] for item in notifications] == [
|
|
||||||
"project_membership_removed",
|
|
||||||
"project_membership_deactivated",
|
|
||||||
"project_membership_role_changed",
|
|
||||||
"project_membership_added",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,146 +1,22 @@
|
|||||||
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 execute(self):
|
|
||||||
results = []
|
|
||||||
for name, args, kwargs in self.operations:
|
|
||||||
results.append(getattr(self.client, name)(*args, **kwargs))
|
|
||||||
self.operations.clear()
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
class FakePubSub:
|
|
||||||
def __init__(self):
|
|
||||||
self.channels = []
|
|
||||||
self.messages = []
|
|
||||||
self.closed = False
|
|
||||||
|
|
||||||
def subscribe(self, channel):
|
|
||||||
self.channels.append(channel)
|
|
||||||
|
|
||||||
def unsubscribe(self, channel):
|
|
||||||
if channel in self.channels:
|
|
||||||
self.channels.remove(channel)
|
|
||||||
|
|
||||||
def get_message(self, timeout=1.0):
|
|
||||||
if self.messages:
|
|
||||||
return self.messages.pop(0)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
self.closed = True
|
|
||||||
|
|
||||||
|
|
||||||
class FakeRedis:
|
|
||||||
def __init__(self):
|
|
||||||
self.sorted_sets = defaultdict(dict)
|
|
||||||
self.hashes = defaultdict(dict)
|
|
||||||
self.sets = defaultdict(set)
|
|
||||||
self.published = []
|
|
||||||
self.pubsub_instance = FakePubSub()
|
|
||||||
|
|
||||||
def pipeline(self):
|
|
||||||
return FakePipeline(self)
|
|
||||||
|
|
||||||
def zadd(self, key, mapping):
|
|
||||||
self.sorted_sets[key].update(mapping)
|
|
||||||
return len(mapping)
|
|
||||||
|
|
||||||
def hset(self, key, field, value):
|
|
||||||
self.hashes[key][field] = value
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def sadd(self, key, *members):
|
|
||||||
before = len(self.sets[key])
|
|
||||||
self.sets[key].update(members)
|
|
||||||
return len(self.sets[key]) - before
|
|
||||||
|
|
||||||
def zrevrange(self, key, start, stop):
|
|
||||||
items = sorted(
|
|
||||||
self.sorted_sets[key].items(),
|
|
||||||
key=lambda item: (item[1], item[0]),
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
if stop == -1:
|
|
||||||
return [member for member, _ in items[start:]]
|
|
||||||
return [member for member, _ in items[start : stop + 1]]
|
|
||||||
|
|
||||||
def hget(self, key, field):
|
|
||||||
return self.hashes[key].get(field)
|
|
||||||
|
|
||||||
def zrem(self, key, *members):
|
|
||||||
removed = 0
|
|
||||||
for member in members:
|
|
||||||
if member in self.sorted_sets[key]:
|
|
||||||
del self.sorted_sets[key][member]
|
|
||||||
removed += 1
|
|
||||||
return removed
|
|
||||||
|
|
||||||
def hdel(self, key, *fields):
|
|
||||||
removed = 0
|
|
||||||
for field in fields:
|
|
||||||
if field in self.hashes[key]:
|
|
||||||
del self.hashes[key][field]
|
|
||||||
removed += 1
|
|
||||||
return removed
|
|
||||||
|
|
||||||
def smembers(self, key):
|
|
||||||
return set(self.sets[key])
|
|
||||||
|
|
||||||
def srem(self, key, member):
|
|
||||||
if member in self.sets[key]:
|
|
||||||
self.sets[key].remove(member)
|
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def zrangebyscore(self, key, min_score, max_score):
|
|
||||||
lower = float("-inf") if min_score == "-inf" else float(min_score)
|
|
||||||
upper = float(max_score)
|
|
||||||
return [
|
|
||||||
member
|
|
||||||
for member, score in self.sorted_sets[key].items()
|
|
||||||
if lower <= score <= upper
|
|
||||||
]
|
|
||||||
|
|
||||||
def zcard(self, key):
|
|
||||||
return len(self.sorted_sets[key])
|
|
||||||
|
|
||||||
def publish(self, channel, message):
|
|
||||||
self.published.append((channel, json.loads(message)))
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def pubsub(self, ignore_subscribe_messages=True):
|
|
||||||
return self.pubsub_instance
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def fake_redis(monkeypatch):
|
|
||||||
redis = FakeRedis()
|
|
||||||
monkeypatch.setattr(services, "redis_client", redis)
|
|
||||||
return redis
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_publishes_notification_and_unread_count(fake_redis, settings):
|
|
||||||
settings.NOTIFICATIONS_ENABLED = True
|
|
||||||
|
|
||||||
|
def test_add_publishes_notification_and_unread_count(self):
|
||||||
|
with self.settings(NOTIFICATIONS_ENABLED=True):
|
||||||
notification = RedisNotificationStore.add(
|
notification = RedisNotificationStore.add(
|
||||||
"user-1",
|
"user-1",
|
||||||
{
|
{
|
||||||
@@ -150,40 +26,42 @@ def test_add_publishes_notification_and_unread_count(fake_redis, settings):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert notification["title"] == "Build finished"
|
self.assertEqual(notification["title"], "Build finished")
|
||||||
assert notification["message"] == "Your deploy completed."
|
self.assertEqual(notification["message"], "Your deploy completed.")
|
||||||
assert notification["level"] == "success"
|
self.assertEqual(notification["level"], "success")
|
||||||
assert len(fake_redis.published) == 2
|
self.assertEqual(len(self.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
|
|
||||||
|
|
||||||
|
channel, payload = self.fake_redis.published[0]
|
||||||
|
self.assertEqual(
|
||||||
|
channel,
|
||||||
|
f"{settings.NOTIFICATION_REDIS_CHANNEL_PREFIX}:user-1",
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["event"], "notification")
|
||||||
|
self.assertEqual(payload["data"]["notification"]["id"], notification["id"])
|
||||||
|
self.assertEqual(payload["data"]["unread_count"], 1)
|
||||||
|
|
||||||
def test_mark_seen_and_mark_all_seen_publish_sync_events(fake_redis, settings):
|
def test_mark_seen_and_mark_all_seen_publish_sync_events(self):
|
||||||
settings.NOTIFICATIONS_ENABLED = True
|
with self.settings(NOTIFICATIONS_ENABLED=True):
|
||||||
first = RedisNotificationStore.add("user-2", {"title": "First"})
|
first = RedisNotificationStore.add("user-2", {"title": "First"})
|
||||||
second = RedisNotificationStore.add("user-2", {"title": "Second"})
|
RedisNotificationStore.add("user-2", {"title": "Second"})
|
||||||
fake_redis.published.clear()
|
self.fake_redis.published.clear()
|
||||||
|
|
||||||
payload = RedisNotificationStore.mark_seen("user-2", first["id"])
|
payload = RedisNotificationStore.mark_seen("user-2", first["id"])
|
||||||
|
|
||||||
assert payload["notification_id"] == first["id"]
|
self.assertEqual(payload["notification_id"], first["id"])
|
||||||
assert payload["deleted"] is False
|
self.assertFalse(payload["deleted"])
|
||||||
assert payload["notification"]["is_seen"] is True
|
self.assertTrue(payload["notification"]["is_seen"])
|
||||||
assert fake_redis.published[0][1]["event"] == "notification_seen"
|
self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_seen")
|
||||||
|
|
||||||
fake_redis.published.clear()
|
self.fake_redis.published.clear()
|
||||||
updated = RedisNotificationStore.mark_all_seen("user-2")
|
updated = RedisNotificationStore.mark_all_seen("user-2")
|
||||||
|
|
||||||
assert updated == 2
|
self.assertEqual(updated, 2)
|
||||||
assert fake_redis.published[0][1]["event"] == "notification_mark_all_read"
|
self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_mark_all_read")
|
||||||
assert fake_redis.published[1][1]["event"] == "unread_count"
|
self.assertEqual(self.fake_redis.published[1][1]["event"], "unread_count")
|
||||||
assert fake_redis.published[1][1]["data"]["unread_count"] == 0
|
self.assertEqual(self.fake_redis.published[1][1]["data"]["unread_count"], 0)
|
||||||
|
|
||||||
|
def test_list_returns_total_count_and_filtered_notifications(self):
|
||||||
def test_list_returns_total_count_and_filtered_notifications(fake_redis):
|
|
||||||
RedisNotificationStore.add("user-3", {"title": "General", "type": "general"})
|
RedisNotificationStore.add("user-3", {"title": "General", "type": "general"})
|
||||||
RedisNotificationStore.add("user-3", {"title": "Billing", "type": "billing"})
|
RedisNotificationStore.add("user-3", {"title": "Billing", "type": "billing"})
|
||||||
RedisNotificationStore.add("user-3", {"title": "General 2", "type": "general"})
|
RedisNotificationStore.add("user-3", {"title": "General 2", "type": "general"})
|
||||||
@@ -195,6 +73,6 @@ def test_list_returns_total_count_and_filtered_notifications(fake_redis):
|
|||||||
type_filter="general",
|
type_filter="general",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert total_count == 2
|
self.assertEqual(total_count, 2)
|
||||||
assert len(notifications) == 1
|
self.assertEqual(len(notifications), 1)
|
||||||
assert notifications[0]["type"] == "general"
|
self.assertEqual(notifications[0]["type"], "general")
|
||||||
|
|||||||
20
apps/notifications/tests/test_tasks.py
Normal file
20
apps/notifications/tests/test_tasks.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.notifications.tasks import cleanup_redis_notifications
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTaskTests(TestCase):
|
||||||
|
@patch("apps.notifications.tasks.RedisNotificationStore.cleanup_expired")
|
||||||
|
def test_cleanup_redis_notifications_uses_settings_retention_days(self, cleanup_expired):
|
||||||
|
cleanup_expired.return_value = 7
|
||||||
|
|
||||||
|
removed = cleanup_redis_notifications()
|
||||||
|
|
||||||
|
self.assertEqual(removed, 7)
|
||||||
|
cleanup_expired.assert_called_once_with(
|
||||||
|
retention_days=settings.NOTIFICATION_RETENTION_DAYS
|
||||||
|
)
|
||||||
|
|
||||||
@@ -1,36 +1,38 @@
|
|||||||
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
|
||||||
@pytest.fixture()
|
def _read_sse_chunks(response, count):
|
||||||
def second_user(db):
|
|
||||||
return User.objects.create_user(mobile="09122222222", password="secret123")
|
|
||||||
|
|
||||||
|
|
||||||
def _read_sse_chunks(response, count):
|
|
||||||
iterator = iter(response.streaming_content)
|
iterator = iter(response.streaming_content)
|
||||||
chunks = []
|
chunks = []
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
@@ -41,67 +43,61 @@ def _read_sse_chunks(response, count):
|
|||||||
response.close()
|
response.close()
|
||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def _parse_sse_data(chunk: str) -> dict:
|
def _parse_sse_data(chunk):
|
||||||
for line in chunk.splitlines():
|
for line in chunk.splitlines():
|
||||||
if line.startswith("data: "):
|
if line.startswith("data: "):
|
||||||
return json.loads(line.removeprefix("data: "))
|
return json.loads(line.removeprefix("data: "))
|
||||||
raise AssertionError("SSE payload did not include data")
|
raise AssertionError("SSE payload did not include data")
|
||||||
|
|
||||||
|
def test_stream_token_endpoint_returns_short_lived_token(self):
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
def test_stream_token_endpoint_returns_short_lived_token(user):
|
response = self.client.post("/api/notifications/stream-token/")
|
||||||
client = APIClient()
|
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
response = client.post("/api/notifications/stream-token/")
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.data["token"])
|
||||||
|
self.assertGreater(response.data["expires_in"], 0)
|
||||||
|
|
||||||
assert response.status_code == 200
|
def test_stream_endpoint_rejects_missing_and_expired_token(self):
|
||||||
assert response.data["token"]
|
missing = self.client.get("/api/notifications/stream/")
|
||||||
assert response.data["expires_in"] > 0
|
self.assertEqual(missing.status_code, 401)
|
||||||
|
|
||||||
|
with override_settings(NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS=1):
|
||||||
def test_stream_endpoint_rejects_missing_and_expired_token(user, settings):
|
token = views._issue_stream_token_for_user(str(self.user.id))
|
||||||
client = APIClient()
|
|
||||||
|
|
||||||
missing = client.get("/api/notifications/stream/")
|
|
||||||
assert missing.status_code == 401
|
|
||||||
|
|
||||||
settings.NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS = 1
|
|
||||||
token = views._issue_stream_token_for_user(str(user.id))
|
|
||||||
time.sleep(1.1)
|
time.sleep(1.1)
|
||||||
|
expired = self.client.get(f"/api/notifications/stream/?token={token}")
|
||||||
|
|
||||||
expired = client.get(f"/api/notifications/stream/?token={token}")
|
self.assertEqual(expired.status_code, 401)
|
||||||
assert expired.status_code == 401
|
|
||||||
|
|
||||||
|
def test_stream_endpoint_sends_only_current_users_notifications(self):
|
||||||
def test_stream_endpoint_sends_only_current_users_notifications(
|
RedisNotificationStore.add(str(self.user.id), {"title": "For current user"})
|
||||||
fake_redis, user, second_user, monkeypatch
|
RedisNotificationStore.add(str(self.second_user.id), {"title": "For another user"})
|
||||||
):
|
|
||||||
RedisNotificationStore.add(str(user.id), {"title": "For current user"})
|
|
||||||
RedisNotificationStore.add(str(second_user.id), {"title": "For another user"})
|
|
||||||
pubsub = FakePubSub()
|
pubsub = FakePubSub()
|
||||||
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub))
|
|
||||||
token = views._issue_stream_token_for_user(str(user.id))
|
|
||||||
|
|
||||||
client = APIClient()
|
with patch.object(
|
||||||
response = client.get(
|
RedisNotificationStore,
|
||||||
|
"get_pubsub",
|
||||||
|
classmethod(lambda cls: pubsub),
|
||||||
|
):
|
||||||
|
token = views._issue_stream_token_for_user(str(self.user.id))
|
||||||
|
response = self.client.get(
|
||||||
f"/api/notifications/stream/?token={token}",
|
f"/api/notifications/stream/?token={token}",
|
||||||
HTTP_ACCEPT="text/event-stream",
|
HTTP_ACCEPT="text/event-stream",
|
||||||
)
|
)
|
||||||
retry_line, connected_chunk = _read_sse_chunks(response, 2)
|
retry_line, connected_chunk = self._read_sse_chunks(response, 2)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert retry_line.startswith("retry:")
|
self.assertTrue(retry_line.startswith("retry:"))
|
||||||
connected = _parse_sse_data(connected_chunk)
|
connected = self._parse_sse_data(connected_chunk)
|
||||||
assert connected["unread_count"] == 1
|
self.assertEqual(connected["unread_count"], 1)
|
||||||
assert [item["title"] for item in connected["notifications"]] == ["For current user"]
|
self.assertEqual(
|
||||||
|
[item["title"] for item in connected["notifications"]],
|
||||||
|
["For current user"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stream_endpoint_emits_heartbeat(self):
|
||||||
def test_stream_endpoint_emits_heartbeat(fake_redis, user, settings, monkeypatch):
|
|
||||||
pubsub = FakePubSub()
|
pubsub = FakePubSub()
|
||||||
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub))
|
|
||||||
settings.NOTIFICATION_SSE_HEARTBEAT_SECONDS = 1
|
|
||||||
|
|
||||||
first_now = timezone.now()
|
first_now = timezone.now()
|
||||||
tick_values = iter(
|
tick_values = iter(
|
||||||
[
|
[
|
||||||
@@ -118,49 +114,55 @@ def test_stream_endpoint_emits_heartbeat(fake_redis, user, settings, monkeypatch
|
|||||||
def fake_now():
|
def fake_now():
|
||||||
return next(tick_values, last_tick)
|
return next(tick_values, last_tick)
|
||||||
|
|
||||||
monkeypatch.setattr(views.timezone, "now", fake_now)
|
with override_settings(NOTIFICATION_SSE_HEARTBEAT_SECONDS=1):
|
||||||
|
with patch.object(
|
||||||
|
RedisNotificationStore,
|
||||||
|
"get_pubsub",
|
||||||
|
classmethod(lambda cls: pubsub),
|
||||||
|
):
|
||||||
|
with patch.object(views.timezone, "now", side_effect=fake_now):
|
||||||
view = views.NotificationStreamView()
|
view = views.NotificationStreamView()
|
||||||
stream = view._build_stream(str(user.id))
|
stream = view._build_stream(str(self.user.id))
|
||||||
|
|
||||||
chunks = [next(stream) for _ in range(4)]
|
chunks = [next(stream) for _ in range(4)]
|
||||||
stream.close()
|
stream.close()
|
||||||
|
|
||||||
assert "event: ping" in chunks[3]
|
self.assertIn("event: ping", chunks[3])
|
||||||
|
|
||||||
|
def test_notification_list_and_seen_endpoints_work(self):
|
||||||
def test_notification_list_and_seen_endpoints_work(fake_redis, user):
|
|
||||||
notification = RedisNotificationStore.add(
|
notification = RedisNotificationStore.add(
|
||||||
str(user.id),
|
str(self.user.id),
|
||||||
{"title": "Deploy succeeded", "type": "deploy"},
|
{"title": "Deploy succeeded", "type": "deploy"},
|
||||||
)
|
)
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
client = APIClient()
|
list_response = self.client.get("/api/notifications/list/?type=deploy")
|
||||||
client.force_authenticate(user=user)
|
self.assertEqual(list_response.status_code, 200)
|
||||||
|
self.assertEqual(list_response.data["count"], 1)
|
||||||
list_response = client.get("/api/notifications/list/?type=deploy")
|
self.assertEqual(list_response.data["unread_count"], 1)
|
||||||
assert list_response.status_code == 200
|
self.assertEqual(
|
||||||
assert list_response.data["count"] == 1
|
list_response.data["notifications"][0]["title"],
|
||||||
assert list_response.data["unread_count"] == 1
|
"Deploy succeeded",
|
||||||
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()
|
seen_response = self.client.post(
|
||||||
client.force_authenticate(user=user)
|
"/api/notifications/seen/",
|
||||||
|
{"id": notification["id"]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(seen_response.status_code, 200)
|
||||||
|
self.assertTrue(seen_response.data["marked_read"])
|
||||||
|
self.assertTrue(seen_response.data["notification"]["is_seen"])
|
||||||
|
|
||||||
response = client.delete(f"/api/notifications/{notification['id']}/")
|
def test_notification_delete_endpoint_removes_notification(self):
|
||||||
|
notification = RedisNotificationStore.add(
|
||||||
|
str(self.user.id),
|
||||||
|
{"title": "Delete me", "type": "deploy"},
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
assert response.status_code == 200
|
response = self.client.delete(f"/api/notifications/{notification['id']}/")
|
||||||
assert response.data["deleted"] is True
|
|
||||||
assert response.data["notification_id"] == notification["id"]
|
self.assertEqual(response.status_code, 200)
|
||||||
assert RedisNotificationStore.get(str(user.id), notification["id"]) is None
|
self.assertTrue(response.data["deleted"])
|
||||||
|
self.assertEqual(response.data["notification_id"], notification["id"])
|
||||||
|
self.assertIsNone(RedisNotificationStore.get(str(self.user.id), notification["id"]))
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from core.admins.base import BaseAdmin
|
from core.admins.base import BaseAdmin, SoftDeleteListFilter
|
||||||
from apps.projects.models import Project, ProjectMembership
|
from apps.projects.models import Project
|
||||||
|
|
||||||
|
|
||||||
class ProjectMembershipInline(admin.TabularInline):
|
|
||||||
model = ProjectMembership
|
|
||||||
extra = 0
|
|
||||||
autocomplete_fields = ("user",)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Project)
|
@admin.register(Project)
|
||||||
@@ -22,6 +16,7 @@ class ProjectAdmin(BaseAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
list_filter = (
|
list_filter = (
|
||||||
|
SoftDeleteListFilter,
|
||||||
"workspace",
|
"workspace",
|
||||||
"is_archived",
|
"is_archived",
|
||||||
"is_deleted",
|
"is_deleted",
|
||||||
@@ -37,32 +32,3 @@ class ProjectAdmin(BaseAdmin):
|
|||||||
"workspace",
|
"workspace",
|
||||||
"client",
|
"client",
|
||||||
)
|
)
|
||||||
|
|
||||||
inlines = (ProjectMembershipInline,)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ProjectMembership)
|
|
||||||
class ProjectMembershipAdmin(BaseAdmin):
|
|
||||||
list_display = (
|
|
||||||
"id",
|
|
||||||
"project",
|
|
||||||
"user",
|
|
||||||
"role",
|
|
||||||
"is_active",
|
|
||||||
)
|
|
||||||
|
|
||||||
list_filter = (
|
|
||||||
"role",
|
|
||||||
"is_active",
|
|
||||||
"is_deleted",
|
|
||||||
)
|
|
||||||
|
|
||||||
search_fields = (
|
|
||||||
"project__name",
|
|
||||||
"user__mobile",
|
|
||||||
)
|
|
||||||
|
|
||||||
autocomplete_fields = (
|
|
||||||
"project",
|
|
||||||
"user",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from apps.projects.models import ProjectMembership
|
|
||||||
from apps.workspaces.services import (
|
from apps.workspaces.services import (
|
||||||
PROJECTS_EDIT,
|
PROJECTS_EDIT,
|
||||||
PROJECTS_VIEW,
|
PROJECTS_VIEW,
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
has_workspace_capability,
|
||||||
has_project_capability,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +16,7 @@ def get_project_from_obj(obj):
|
|||||||
|
|
||||||
class IsProjectMember(permissions.BasePermission):
|
class IsProjectMember(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
Allows access only to users who have an active membership in the project.
|
Allows access to users who can view projects in the parent workspace.
|
||||||
"""
|
"""
|
||||||
message = "شما عضو این پروژه نیستید."
|
message = "شما عضو این پروژه نیستید."
|
||||||
|
|
||||||
@@ -27,12 +25,12 @@ class IsProjectMember(permissions.BasePermission):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
project = get_project_from_obj(obj)
|
project = get_project_from_obj(obj)
|
||||||
return has_project_capability(request.user, project, PROJECTS_VIEW)
|
return has_workspace_capability(request.user, project.workspace, PROJECTS_VIEW)
|
||||||
|
|
||||||
|
|
||||||
class IsProjectManager(permissions.BasePermission):
|
class IsProjectManager(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
Allows access only to users who are active MANAGERs of the project.
|
Allows access to users who can manage projects in the parent workspace.
|
||||||
"""
|
"""
|
||||||
message = "فقط مدیران پروژه مجاز به انجام این عملیات هستند."
|
message = "فقط مدیران پروژه مجاز به انجام این عملیات هستند."
|
||||||
|
|
||||||
@@ -41,19 +39,4 @@ class IsProjectManager(permissions.BasePermission):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
project = get_project_from_obj(obj)
|
project = get_project_from_obj(obj)
|
||||||
return has_project_capability(request.user, project, PROJECTS_EDIT)
|
return has_workspace_capability(request.user, project.workspace, PROJECTS_EDIT)
|
||||||
|
|
||||||
|
|
||||||
class CanManageProjectMembers(permissions.BasePermission):
|
|
||||||
message = "Only authorized users can manage project memberships."
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
if not request.user or not request.user.is_authenticated:
|
|
||||||
return False
|
|
||||||
|
|
||||||
project = get_project_from_obj(obj)
|
|
||||||
return has_project_capability(
|
|
||||||
request.user,
|
|
||||||
project,
|
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from core.serializers.base import BaseModelSerializer
|
from core.serializers.base import BaseModelSerializer
|
||||||
from apps.projects.models import (
|
from apps.projects.models import Project
|
||||||
Project,
|
from apps.workspaces.models import PriceUnit
|
||||||
ProjectMembership,
|
|
||||||
)
|
|
||||||
from core.serializers.mini import UserMiniSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectMemberInputSerializer(serializers.Serializer):
|
def validate_thumbnail(value):
|
||||||
user_id = serializers.UUIDField()
|
if value is None:
|
||||||
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, default=ProjectMembership.Role.MEMBER)
|
return value
|
||||||
|
max_bytes = 2 * 1024 * 1024
|
||||||
|
if getattr(value, "size", 0) > max_bytes:
|
||||||
|
raise serializers.ValidationError("Image size must be 2MB or less.")
|
||||||
|
content_type = (getattr(value, "content_type", "") or "").lower()
|
||||||
|
allowed_types = {"image/jpeg", "image/png", "image/webp"}
|
||||||
|
if content_type and content_type not in allowed_types:
|
||||||
|
raise serializers.ValidationError("Unsupported image type. Use JPG, PNG, or WebP.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ProjectSerializer(BaseModelSerializer):
|
class ProjectSerializer(BaseModelSerializer):
|
||||||
my_role = serializers.SerializerMethodField()
|
|
||||||
members = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Project
|
model = Project
|
||||||
fields = BaseModelSerializer.Meta.fields + (
|
fields = BaseModelSerializer.Meta.fields + (
|
||||||
@@ -23,33 +27,31 @@ class ProjectSerializer(BaseModelSerializer):
|
|||||||
"name",
|
"name",
|
||||||
"client",
|
"client",
|
||||||
"description",
|
"description",
|
||||||
|
"thumbnail",
|
||||||
"is_archived",
|
"is_archived",
|
||||||
"color",
|
"color",
|
||||||
"my_role",
|
|
||||||
"members",
|
|
||||||
)
|
)
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
def get_my_role(self, obj):
|
|
||||||
request = self.context.get("request")
|
|
||||||
if not request or not request.user.is_authenticated:
|
|
||||||
return None
|
|
||||||
membership = obj.memberships.filter(user=request.user, is_active=True, is_deleted=False).first()
|
|
||||||
return getattr(membership, "role", None)
|
|
||||||
|
|
||||||
def get_members(self, obj):
|
|
||||||
"""
|
|
||||||
Returns active project members in the response
|
|
||||||
"""
|
|
||||||
active_memberships = obj.memberships.filter(is_active=True, is_deleted=False)
|
|
||||||
return ProjectMembershipSerializer(active_memberships, many=True).data
|
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
representation = super().to_representation(instance)
|
representation = super().to_representation(instance)
|
||||||
|
request = self.context.get("request")
|
||||||
|
if instance.thumbnail:
|
||||||
|
thumbnail_url = instance.thumbnail.url
|
||||||
|
representation["thumbnail"] = request.build_absolute_uri(thumbnail_url) if request else thumbnail_url
|
||||||
|
else:
|
||||||
|
representation["thumbnail"] = None
|
||||||
if instance.client:
|
if instance.client:
|
||||||
representation['client'] = {
|
representation['client'] = {
|
||||||
'id': instance.client.id,
|
'id': instance.client.id,
|
||||||
'name': instance.client.name
|
'name': instance.client.name,
|
||||||
|
'thumbnail': (
|
||||||
|
request.build_absolute_uri(instance.client.thumbnail.url)
|
||||||
|
if request and instance.client.thumbnail
|
||||||
|
else instance.client.thumbnail.url
|
||||||
|
if instance.client.thumbnail
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
return representation
|
return representation
|
||||||
|
|
||||||
@@ -59,39 +61,55 @@ class ProjectCreateSerializer(serializers.Serializer):
|
|||||||
name = serializers.CharField(max_length=255)
|
name = serializers.CharField(max_length=255)
|
||||||
client = serializers.UUIDField(required=False, allow_null=True)
|
client = serializers.UUIDField(required=False, allow_null=True)
|
||||||
description = serializers.CharField(required=False, allow_blank=True, default="")
|
description = serializers.CharField(required=False, allow_blank=True, default="")
|
||||||
|
thumbnail = serializers.ImageField(required=False, allow_null=True)
|
||||||
color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="")
|
color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="")
|
||||||
members = ProjectMemberInputSerializer(many=True, required=False)
|
|
||||||
|
def validate_thumbnail(self, value):
|
||||||
|
return validate_thumbnail(value)
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdateSerializer(serializers.Serializer):
|
class ProjectUpdateSerializer(serializers.Serializer):
|
||||||
name = serializers.CharField(max_length=255, required=False)
|
name = serializers.CharField(max_length=255, required=False)
|
||||||
client = serializers.UUIDField(required=False, allow_null=True)
|
client = serializers.UUIDField(required=False, allow_null=True)
|
||||||
description = serializers.CharField(required=False, allow_blank=True)
|
description = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
thumbnail = serializers.ImageField(required=False, allow_null=True)
|
||||||
|
clear_thumbnail = serializers.BooleanField(required=False, default=False)
|
||||||
color = serializers.CharField(max_length=7, required=False, allow_blank=True)
|
color = serializers.CharField(max_length=7, required=False, allow_blank=True)
|
||||||
is_archived = serializers.BooleanField(required=False)
|
is_archived = serializers.BooleanField(required=False)
|
||||||
members = ProjectMemberInputSerializer(many=True, required=False)
|
|
||||||
|
|
||||||
class ProjectMembershipSerializer(BaseModelSerializer):
|
def validate_thumbnail(self, value):
|
||||||
user_details = UserMiniSerializer(read_only=True)
|
return validate_thumbnail(value)
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ProjectMembership
|
class ProjectAccessQuerySerializer(serializers.Serializer):
|
||||||
fields = BaseModelSerializer.Meta.fields + (
|
workspace = serializers.UUIDField()
|
||||||
"project",
|
user = serializers.UUIDField()
|
||||||
"user",
|
|
||||||
"user_details",
|
|
||||||
"role",
|
class ProjectAccessMutationSerializer(serializers.Serializer):
|
||||||
"is_active",
|
workspace = serializers.UUIDField()
|
||||||
|
user = serializers.UUIDField()
|
||||||
|
project_ids = serializers.ListField(
|
||||||
|
child=serializers.UUIDField(),
|
||||||
|
allow_empty=False,
|
||||||
)
|
)
|
||||||
read_only_fields = fields
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectMembershipCreateSerializer(serializers.Serializer):
|
class ProjectAccessRateMutationSerializer(serializers.Serializer):
|
||||||
project_id = serializers.UUIDField()
|
workspace = serializers.UUIDField()
|
||||||
user_id = serializers.UUIDField()
|
user = serializers.UUIDField()
|
||||||
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices)
|
project = serializers.UUIDField()
|
||||||
|
hourly_rate = serializers.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
min_value=Decimal("0.01"),
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
)
|
||||||
|
currency = serializers.CharField(max_length=3, required=False, default="USD")
|
||||||
|
|
||||||
|
def validate_currency(self, value):
|
||||||
class ProjectMembershipUpdateSerializer(serializers.Serializer):
|
code = value.upper()
|
||||||
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, required=False)
|
if not PriceUnit.objects.filter(code=code, is_deleted=False).exists():
|
||||||
is_active = serializers.BooleanField(required=False)
|
raise serializers.ValidationError("Selected price unit is invalid.")
|
||||||
|
return code
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from apps.projects.api.views import (
|
from apps.projects.api.views import ProjectViewSet
|
||||||
ProjectViewSet,
|
|
||||||
ProjectMembershipViewSet,
|
|
||||||
)
|
|
||||||
|
|
||||||
app_name = "projects"
|
app_name = "projects"
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r"projects", ProjectViewSet, basename="project")
|
router.register(r"projects", ProjectViewSet, basename="project")
|
||||||
router.register(r"memberships", ProjectMembershipViewSet, basename="membership")
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from django.shortcuts import get_object_or_404
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.exceptions import PermissionDenied
|
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.filters import SearchFilter, OrderingFilter
|
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||||
@@ -11,39 +10,37 @@ from django_filters.rest_framework import DjangoFilterBackend
|
|||||||
|
|
||||||
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
||||||
|
|
||||||
from apps.notifications.services import (
|
|
||||||
notify_project_membership_added,
|
|
||||||
notify_project_membership_deactivated,
|
|
||||||
notify_project_membership_removed,
|
|
||||||
notify_project_membership_role_changed,
|
|
||||||
)
|
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import (
|
from apps.projects.models import Project
|
||||||
Project,
|
|
||||||
ProjectMembership,
|
|
||||||
)
|
|
||||||
from apps.projects.api.serializers import (
|
from apps.projects.api.serializers import (
|
||||||
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
|
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
|
||||||
ProjectMembershipSerializer, ProjectMembershipCreateSerializer, ProjectMembershipUpdateSerializer,
|
ProjectAccessMutationSerializer, ProjectAccessQuerySerializer,
|
||||||
|
ProjectAccessRateMutationSerializer,
|
||||||
)
|
)
|
||||||
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
|
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
|
||||||
|
from apps.projects.services.access import (
|
||||||
|
build_project_access_item,
|
||||||
|
build_project_access_items,
|
||||||
|
ensure_workspace_project_access,
|
||||||
|
filter_projects_for_user,
|
||||||
|
get_project_access_target_membership,
|
||||||
|
grant_project_accesses,
|
||||||
|
revoke_project_accesses,
|
||||||
|
user_has_project_access,
|
||||||
|
)
|
||||||
|
from apps.projects.services.rates import get_current_project_user_rate, remove_project_user_rate, upsert_project_user_rate
|
||||||
from apps.projects.services.projects import (
|
from apps.projects.services.projects import (
|
||||||
create_project,
|
create_project,
|
||||||
update_project,
|
update_project,
|
||||||
toggle_project_archive
|
toggle_project_archive
|
||||||
)
|
)
|
||||||
from apps.projects.services.memberships import add_project_member, update_project_member
|
|
||||||
from apps.workspaces.services import (
|
from apps.workspaces.services import (
|
||||||
PROJECTS_ARCHIVE,
|
PROJECTS_ARCHIVE,
|
||||||
PROJECTS_CREATE,
|
PROJECTS_CREATE,
|
||||||
PROJECTS_DELETE,
|
PROJECTS_DELETE,
|
||||||
PROJECTS_EDIT,
|
PROJECTS_EDIT,
|
||||||
PROJECT_MEMBERS_ADD,
|
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
|
||||||
PROJECT_MEMBERS_REMOVE,
|
|
||||||
can_delete_workspace_object,
|
can_delete_workspace_object,
|
||||||
has_project_capability,
|
|
||||||
has_workspace_capability,
|
has_workspace_capability,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,9 +60,9 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
"""
|
"""
|
||||||
Instantiates and returns the list of permissions that this view requires.
|
Instantiates and returns the list of permissions that this view requires.
|
||||||
- Managers can update, delete, or archive.
|
- Workspace-authorized users can update, delete, or archive.
|
||||||
- Members can retrieve/view.
|
- Workspace members can retrieve/view.
|
||||||
- Any authenticated user can list (filtered to their memberships) or attempt to create.
|
- Any authenticated user can list their workspace projects or attempt to create.
|
||||||
"""
|
"""
|
||||||
if self.action in ["update", "partial_update", "destroy", "archive"]:
|
if self.action in ["update", "partial_update", "destroy", "archive"]:
|
||||||
permission_classes = [IsAuthenticated, IsProjectManager]
|
permission_classes = [IsAuthenticated, IsProjectManager]
|
||||||
@@ -78,16 +75,24 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""
|
"""
|
||||||
Returns active projects where the current user is an active member.
|
Returns active projects in workspaces where the current user is an active member.
|
||||||
"""
|
"""
|
||||||
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
|
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
|
||||||
return Project.objects.none()
|
return Project.objects.none()
|
||||||
|
|
||||||
return Project.objects.filter(
|
queryset = filter_projects_for_user(
|
||||||
workspace__memberships__user=self.request.user,
|
self.request.user,
|
||||||
workspace__memberships__is_active=True,
|
Project.objects.filter(is_deleted=False),
|
||||||
is_deleted=False
|
)
|
||||||
).distinct()
|
|
||||||
|
client_ids = [client_id for client_id in self.request.query_params.getlist("clients") if client_id]
|
||||||
|
if client_ids:
|
||||||
|
queryset = queryset.filter(client_id__in=client_ids)
|
||||||
|
|
||||||
|
if "is_archived" not in self.request.query_params:
|
||||||
|
queryset = queryset.filter(is_archived=False)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
"""
|
"""
|
||||||
@@ -106,8 +111,6 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
members_data = serializer.validated_data.pop("members", [])
|
|
||||||
|
|
||||||
workspace = get_object_or_404(Workspace, id=serializer.validated_data["workspace"], is_deleted=False)
|
workspace = get_object_or_404(Workspace, id=serializer.validated_data["workspace"], is_deleted=False)
|
||||||
if not has_workspace_capability(request.user, workspace, PROJECTS_CREATE):
|
if not has_workspace_capability(request.user, workspace, PROJECTS_CREATE):
|
||||||
return Response(
|
return Response(
|
||||||
@@ -123,23 +126,11 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
name=serializer.validated_data["name"],
|
name=serializer.validated_data["name"],
|
||||||
client=client,
|
client=client,
|
||||||
description=serializer.validated_data.get("description", ""),
|
description=serializer.validated_data.get("description", ""),
|
||||||
color=serializer.validated_data.get("color", "")
|
color=serializer.validated_data.get("color", ""),
|
||||||
|
thumbnail=serializer.validated_data.get("thumbnail"),
|
||||||
)
|
)
|
||||||
|
|
||||||
for member in members_data:
|
output_serializer = ProjectSerializer(project, context={"request": request})
|
||||||
membership = add_project_member(
|
|
||||||
project=project,
|
|
||||||
user_id=member["user_id"],
|
|
||||||
role=member["role"]
|
|
||||||
)
|
|
||||||
notify_project_membership_added(
|
|
||||||
actor=request.user,
|
|
||||||
recipient=membership.user,
|
|
||||||
project=project,
|
|
||||||
role=membership.role,
|
|
||||||
)
|
|
||||||
|
|
||||||
output_serializer = ProjectSerializer(project)
|
|
||||||
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
@@ -152,70 +143,12 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
serializer = self.get_serializer(data=request.data, partial=partial)
|
serializer = self.get_serializer(data=request.data, partial=partial)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
members_data = serializer.validated_data.pop("members", None)
|
|
||||||
|
|
||||||
updated_project = update_project(
|
updated_project = update_project(
|
||||||
project=project,
|
project=project,
|
||||||
**serializer.validated_data
|
**serializer.validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
# Full sync of project members if array is provided
|
output_serializer = ProjectSerializer(updated_project, context={"request": request})
|
||||||
if members_data is not None:
|
|
||||||
current_memberships = {str(m.user_id): m for m in updated_project.memberships.filter(is_deleted=False)}
|
|
||||||
incoming_users = {str(m['user_id']) for m in members_data}
|
|
||||||
|
|
||||||
# Add or Update roles
|
|
||||||
for member in members_data:
|
|
||||||
user_id_str = str(member['user_id'])
|
|
||||||
if user_id_str in current_memberships:
|
|
||||||
membership = current_memberships[user_id_str]
|
|
||||||
previous_role = membership.role
|
|
||||||
previous_is_active = membership.is_active
|
|
||||||
updated_membership = update_project_member(
|
|
||||||
membership,
|
|
||||||
role=member['role'],
|
|
||||||
is_active=True
|
|
||||||
)
|
|
||||||
if not previous_is_active and updated_membership.is_active:
|
|
||||||
notify_project_membership_added(
|
|
||||||
actor=request.user,
|
|
||||||
recipient=updated_membership.user,
|
|
||||||
project=updated_project,
|
|
||||||
role=updated_membership.role,
|
|
||||||
)
|
|
||||||
elif previous_role != updated_membership.role:
|
|
||||||
notify_project_membership_role_changed(
|
|
||||||
actor=request.user,
|
|
||||||
recipient=updated_membership.user,
|
|
||||||
project=updated_project,
|
|
||||||
previous_role=previous_role,
|
|
||||||
new_role=updated_membership.role,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
membership = add_project_member(
|
|
||||||
project=updated_project,
|
|
||||||
user_id=member['user_id'],
|
|
||||||
role=member['role']
|
|
||||||
)
|
|
||||||
notify_project_membership_added(
|
|
||||||
actor=request.user,
|
|
||||||
recipient=membership.user,
|
|
||||||
project=updated_project,
|
|
||||||
role=membership.role,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Deactivate omitted members
|
|
||||||
for user_id_str, membership in current_memberships.items():
|
|
||||||
if user_id_str not in incoming_users and membership.is_active:
|
|
||||||
update_project_member(membership, is_active=False)
|
|
||||||
notify_project_membership_deactivated(
|
|
||||||
actor=request.user,
|
|
||||||
recipient=membership.user,
|
|
||||||
project=updated_project,
|
|
||||||
role=membership.role,
|
|
||||||
)
|
|
||||||
|
|
||||||
output_serializer = ProjectSerializer(updated_project)
|
|
||||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
@@ -240,125 +173,120 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
project = self.get_object()
|
project = self.get_object()
|
||||||
updated_project = toggle_project_archive(project)
|
updated_project = toggle_project_archive(project)
|
||||||
|
|
||||||
output_serializer = ProjectSerializer(updated_project)
|
output_serializer = ProjectSerializer(updated_project, context={"request": request})
|
||||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], url_path="access")
|
||||||
|
def access(self, request):
|
||||||
|
serializer = ProjectAccessQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
class BaseProjectNestedViewSet(ModelViewSet):
|
workspace = get_object_or_404(
|
||||||
"""
|
Workspace,
|
||||||
Base ViewSet for nested project models to share common permission and queryset logic.
|
id=serializer.validated_data["workspace"],
|
||||||
"""
|
is_deleted=False,
|
||||||
pagination_class = CustomLimitOffsetPagination
|
)
|
||||||
filter_backends = [DjangoFilterBackend, OrderingFilter]
|
ensure_workspace_project_access(request.user, workspace)
|
||||||
ordering = ["-updated_at", "-created_at"]
|
membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"]))
|
||||||
|
|
||||||
def get_permissions(self):
|
return Response(
|
||||||
if self.action in ["create", "update", "partial_update", "destroy"]:
|
{
|
||||||
permission_classes = [IsAuthenticated, IsProjectManager]
|
"workspace": {"id": str(workspace.id), "name": workspace.name},
|
||||||
|
"user": {
|
||||||
|
"id": str(membership.user_id),
|
||||||
|
"name": membership.user.full_name or membership.user.mobile,
|
||||||
|
"mobile": membership.user.mobile,
|
||||||
|
"role": membership.role,
|
||||||
|
},
|
||||||
|
"items": build_project_access_items(workspace=workspace, target_user=membership.user),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], url_path="access/grant")
|
||||||
|
def grant_access(self, request):
|
||||||
|
serializer = ProjectAccessMutationSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
workspace = get_object_or_404(
|
||||||
|
Workspace,
|
||||||
|
id=serializer.validated_data["workspace"],
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"]))
|
||||||
|
changed = grant_project_accesses(
|
||||||
|
actor=request.user,
|
||||||
|
workspace=workspace,
|
||||||
|
target_user=membership.user,
|
||||||
|
project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]],
|
||||||
|
)
|
||||||
|
return Response({"changed": changed}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], url_path="access/revoke")
|
||||||
|
def revoke_access(self, request):
|
||||||
|
serializer = ProjectAccessMutationSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
workspace = get_object_or_404(
|
||||||
|
Workspace,
|
||||||
|
id=serializer.validated_data["workspace"],
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"]))
|
||||||
|
changed = revoke_project_accesses(
|
||||||
|
actor=request.user,
|
||||||
|
workspace=workspace,
|
||||||
|
target_user=membership.user,
|
||||||
|
project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]],
|
||||||
|
)
|
||||||
|
return Response({"changed": changed}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], url_path="access/rate")
|
||||||
|
def set_access_rate(self, request):
|
||||||
|
serializer = ProjectAccessRateMutationSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
workspace = get_object_or_404(
|
||||||
|
Workspace,
|
||||||
|
id=serializer.validated_data["workspace"],
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
ensure_workspace_project_access(request.user, workspace)
|
||||||
|
membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"]))
|
||||||
|
project = get_object_or_404(
|
||||||
|
Project,
|
||||||
|
id=serializer.validated_data["project"],
|
||||||
|
workspace=workspace,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
has_access = user_has_project_access(membership.user, project)
|
||||||
|
if not has_access:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Grant project access before setting a project-specific rate."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
removed = serializer.validated_data.get("hourly_rate") is None
|
||||||
|
if removed:
|
||||||
|
remove_project_user_rate(project=project, user=membership.user)
|
||||||
else:
|
else:
|
||||||
permission_classes = [IsAuthenticated, IsProjectMember]
|
upsert_project_user_rate(
|
||||||
return [permission() for permission in permission_classes]
|
|
||||||
|
|
||||||
def verify_manager_access(self, project_id):
|
|
||||||
"""Helper to verify if the requesting user is a manager of the target project."""
|
|
||||||
project = get_object_or_404(Project, id=project_id, is_deleted=False)
|
|
||||||
if not has_project_capability(self.request.user, project, PROJECT_MEMBERS_ADD):
|
|
||||||
raise PermissionDenied("You must be a project manager to perform this action.")
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectMembershipViewSet(BaseProjectNestedViewSet):
|
|
||||||
filterset_fields = ["project", "user", "role", "is_active"]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
|
|
||||||
return ProjectMembership.objects.none()
|
|
||||||
return ProjectMembership.objects.filter(
|
|
||||||
project__memberships__user=self.request.user,
|
|
||||||
project__memberships__is_active=True,
|
|
||||||
is_deleted=False
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.action == "create": return ProjectMembershipCreateSerializer
|
|
||||||
if self.action in ["update", "partial_update"]: return ProjectMembershipUpdateSerializer
|
|
||||||
return ProjectMembershipSerializer
|
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
project_id = serializer.validated_data["project_id"]
|
|
||||||
self.verify_manager_access(project_id)
|
|
||||||
|
|
||||||
project = get_object_or_404(Project, id=project_id, is_deleted=False)
|
|
||||||
membership = add_project_member(
|
|
||||||
project=project,
|
project=project,
|
||||||
user_id=serializer.validated_data["user_id"],
|
user=membership.user,
|
||||||
role=serializer.validated_data["role"]
|
hourly_rate=serializer.validated_data["hourly_rate"],
|
||||||
|
currency=serializer.validated_data.get("currency", "USD"),
|
||||||
)
|
)
|
||||||
notify_project_membership_added(
|
|
||||||
actor=request.user,
|
workspace_rate = (
|
||||||
recipient=membership.user,
|
workspace.user_rates.filter(user=membership.user, is_deleted=False)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
project_rate = get_current_project_user_rate(project=project, user=membership.user)
|
||||||
|
item = build_project_access_item(
|
||||||
project=project,
|
project=project,
|
||||||
role=membership.role,
|
has_access=True,
|
||||||
|
workspace_rate=workspace_rate,
|
||||||
|
project_rate=project_rate,
|
||||||
)
|
)
|
||||||
return Response(ProjectMembershipSerializer(membership).data, status=status.HTTP_201_CREATED)
|
return Response({"removed": removed, "item": item}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
|
||||||
membership = self.get_object()
|
|
||||||
if not has_project_capability(
|
|
||||||
request.user,
|
|
||||||
membership.project,
|
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
|
||||||
):
|
|
||||||
raise PermissionDenied("You do not have permission to update project members.")
|
|
||||||
serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False))
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
previous_role = membership.role
|
|
||||||
previous_is_active = membership.is_active
|
|
||||||
updated_membership = update_project_member(membership, **serializer.validated_data)
|
|
||||||
if not previous_is_active and updated_membership.is_active:
|
|
||||||
notify_project_membership_added(
|
|
||||||
actor=request.user,
|
|
||||||
recipient=updated_membership.user,
|
|
||||||
project=updated_membership.project,
|
|
||||||
role=updated_membership.role,
|
|
||||||
)
|
|
||||||
elif previous_is_active and not updated_membership.is_active:
|
|
||||||
notify_project_membership_deactivated(
|
|
||||||
actor=request.user,
|
|
||||||
recipient=updated_membership.user,
|
|
||||||
project=updated_membership.project,
|
|
||||||
role=previous_role,
|
|
||||||
)
|
|
||||||
elif previous_role != updated_membership.role:
|
|
||||||
notify_project_membership_role_changed(
|
|
||||||
actor=request.user,
|
|
||||||
recipient=updated_membership.user,
|
|
||||||
project=updated_membership.project,
|
|
||||||
previous_role=previous_role,
|
|
||||||
new_role=updated_membership.role,
|
|
||||||
)
|
|
||||||
return Response(ProjectMembershipSerializer(updated_membership).data, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
|
||||||
membership = self.get_object()
|
|
||||||
if not has_project_capability(
|
|
||||||
request.user,
|
|
||||||
membership.project,
|
|
||||||
PROJECT_MEMBERS_REMOVE,
|
|
||||||
):
|
|
||||||
raise PermissionDenied("You do not have permission to remove project members.")
|
|
||||||
recipient = membership.user
|
|
||||||
project = membership.project
|
|
||||||
role = membership.role
|
|
||||||
membership.is_deleted = True
|
|
||||||
membership.save(update_fields=["is_deleted", "updated_at"])
|
|
||||||
notify_project_membership_removed(
|
|
||||||
actor=request.user,
|
|
||||||
recipient=recipient,
|
|
||||||
project=project,
|
|
||||||
role=role,
|
|
||||||
)
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|||||||
13
apps/projects/migrations/0002_remove_projectmembership.py
Normal file
13
apps/projects/migrations/0002_remove_projectmembership.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("projects", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="ProjectMembership",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
38
apps/projects/migrations/0004_projectaccess.py
Normal file
38
apps/projects/migrations/0004_projectaccess.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-05-13 12:30
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('projects', '0003_project_project_ws_arch_upd_idx'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ProjectAccess',
|
||||||
|
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)),
|
||||||
|
('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)),
|
||||||
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_memberships', to='projects.project')),
|
||||||
|
('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='project_accesses', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'project_access',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
'indexes': [models.Index(fields=['project'], name='project_access_project_idx'), models.Index(fields=['user'], name='project_access_user_idx')],
|
||||||
|
'constraints': [models.UniqueConstraint(condition=models.Q(('is_deleted', False)), fields=('project', 'user'), name='unique_project_access')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
apps/projects/migrations/0005_project_thumbnail.py
Normal file
18
apps/projects/migrations/0005_project_thumbnail.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-05-26 08:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('projects', '0004_projectaccess'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='project',
|
||||||
|
name='thumbnail',
|
||||||
|
field=models.ImageField(blank=True, null=True, upload_to='profile/projects/'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from apps.logs.services import build_workspace_log_metadata
|
from apps.logs.services import build_workspace_log_metadata
|
||||||
from apps.logs.services.constants import SECTION_PROJECTS, SECTION_PROJECT_MEMBERS
|
from apps.logs.services.constants import SECTION_PROJECTS
|
||||||
from core.models.base import BaseModel
|
from core.models.base import BaseModel
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
@@ -28,6 +28,8 @@ class Project(BaseModel):
|
|||||||
|
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
|
thumbnail = models.ImageField(upload_to="profile/projects/", blank=True, null=True)
|
||||||
|
|
||||||
is_archived = models.BooleanField(default=False)
|
is_archived = models.BooleanField(default=False)
|
||||||
|
|
||||||
color = models.CharField(max_length=7, blank=True)
|
color = models.CharField(max_length=7, blank=True)
|
||||||
@@ -37,6 +39,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(
|
||||||
@@ -59,64 +62,6 @@ class Project(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProjectMembership(BaseModel):
|
|
||||||
|
|
||||||
class Role(models.TextChoices):
|
|
||||||
MANAGER = "manager", "Manager"
|
|
||||||
MEMBER = "member", "Member"
|
|
||||||
|
|
||||||
project = models.ForeignKey(
|
|
||||||
Project,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="memberships",
|
|
||||||
)
|
|
||||||
|
|
||||||
user = models.ForeignKey(
|
|
||||||
User,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="project_memberships",
|
|
||||||
)
|
|
||||||
|
|
||||||
role = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=Role.choices,
|
|
||||||
default=Role.MEMBER,
|
|
||||||
)
|
|
||||||
|
|
||||||
is_active = models.BooleanField(default=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "project_membership"
|
|
||||||
ordering = ("-created_at",)
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["project"], name="project_membership_project_idx"),
|
|
||||||
models.Index(fields=["user"], name="project_membership_user_idx"),
|
|
||||||
]
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["project", "user"],
|
|
||||||
name="unique_project_membership",
|
|
||||||
condition=models.Q(is_deleted=False),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.user} @ {self.project}"
|
|
||||||
|
|
||||||
def get_additional_data(self):
|
|
||||||
return build_workspace_log_metadata(
|
|
||||||
section=SECTION_PROJECT_MEMBERS,
|
|
||||||
workspace_id=self.project.workspace_id,
|
|
||||||
target_id=self.id,
|
|
||||||
target_label=self.user.full_name or self.user.mobile,
|
|
||||||
extra={
|
|
||||||
"project_id": str(self.project_id),
|
|
||||||
"member_user_id": str(self.user_id),
|
|
||||||
"role": self.role,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectRate(BaseModel):
|
class ProjectRate(BaseModel):
|
||||||
project = models.ForeignKey(
|
project = models.ForeignKey(
|
||||||
Project,
|
Project,
|
||||||
@@ -187,3 +132,31 @@ class ProjectUserRate(BaseModel):
|
|||||||
models.Index(fields=["project"], name="pur_project_idx"),
|
models.Index(fields=["project"], name="pur_project_idx"),
|
||||||
models.Index(fields=["user"], name="pur_user_idx"),
|
models.Index(fields=["user"], name="pur_user_idx"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectAccess(BaseModel):
|
||||||
|
project = models.ForeignKey(
|
||||||
|
Project,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="access_memberships",
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="project_accesses",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "project_access"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["project", "user"],
|
||||||
|
name="unique_project_access",
|
||||||
|
condition=models.Q(is_deleted=False),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["project"], name="project_access_project_idx"),
|
||||||
|
models.Index(fields=["user"], name="project_access_user_idx"),
|
||||||
|
]
|
||||||
|
|||||||
193
apps/projects/services/access.py
Normal file
193
apps/projects/services/access.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Q, QuerySet
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||||
|
|
||||||
|
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
|
||||||
|
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
|
from apps.workspaces.services import PROJECTS_EDIT, get_workspace_role, has_workspace_capability
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
PROJECT_ACCESS_MANAGED_ROLES = {
|
||||||
|
WorkspaceMembership.Role.MEMBER,
|
||||||
|
WorkspaceMembership.Role.GUEST,
|
||||||
|
}
|
||||||
|
PROJECT_ACCESS_IMPLICIT_ROLES = {
|
||||||
|
WorkspaceMembership.Role.OWNER,
|
||||||
|
WorkspaceMembership.Role.ADMIN,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_implicit_project_access(user, workspace: Workspace) -> bool:
|
||||||
|
return get_workspace_role(user, workspace) in PROJECT_ACCESS_IMPLICIT_ROLES
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_project_access(user, project: Project) -> bool:
|
||||||
|
if not user or not getattr(user, "is_authenticated", False):
|
||||||
|
return False
|
||||||
|
if user_has_implicit_project_access(user, project.workspace):
|
||||||
|
return True
|
||||||
|
return ProjectAccess.objects.filter(project=project, user=user).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def filter_projects_for_user(user, queryset: QuerySet[Project] | None = None) -> QuerySet[Project]:
|
||||||
|
if queryset is None:
|
||||||
|
queryset = Project.objects.all()
|
||||||
|
|
||||||
|
if not user or not getattr(user, "is_authenticated", False):
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
return queryset.filter(
|
||||||
|
Q(workspace__owner=user)
|
||||||
|
| Q(
|
||||||
|
workspace__memberships__user=user,
|
||||||
|
workspace__memberships__is_active=True,
|
||||||
|
workspace__memberships__role__in=PROJECT_ACCESS_IMPLICIT_ROLES,
|
||||||
|
)
|
||||||
|
| Q(
|
||||||
|
workspace__memberships__user=user,
|
||||||
|
workspace__memberships__is_active=True,
|
||||||
|
workspace__memberships__role__in=PROJECT_ACCESS_MANAGED_ROLES,
|
||||||
|
access_memberships__user=user,
|
||||||
|
)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_project_access(user, project: Project, *, message: str = "Selected project is unavailable.") -> None:
|
||||||
|
if not user_has_project_access(user, project):
|
||||||
|
raise ValidationError({"project_id": message})
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_workspace_project_access(user, workspace: Workspace) -> None:
|
||||||
|
if not has_workspace_capability(user, workspace, PROJECTS_EDIT):
|
||||||
|
raise PermissionDenied("You do not have permission to manage project access in this workspace.")
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_access_target_membership(workspace: Workspace, user_id: str) -> WorkspaceMembership:
|
||||||
|
membership = WorkspaceMembership.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
user_id=user_id,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
).select_related("user").first()
|
||||||
|
if not membership:
|
||||||
|
raise ValidationError({"user": "Selected user is not an active member of this workspace."})
|
||||||
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_rate(rate) -> dict | None:
|
||||||
|
if not rate:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": str(rate.id),
|
||||||
|
"hourly_rate": str(rate.hourly_rate),
|
||||||
|
"currency": rate.currency,
|
||||||
|
"effective_from": rate.effective_from.isoformat() if rate.effective_from else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_project_access_item(*, project: Project, has_access: bool, workspace_rate, project_rate) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(project.id),
|
||||||
|
"name": project.name,
|
||||||
|
"description": project.description,
|
||||||
|
"color": project.color,
|
||||||
|
"is_archived": project.is_archived,
|
||||||
|
"client": (
|
||||||
|
{"id": str(project.client_id), "name": project.client.name}
|
||||||
|
if project.client_id and project.client
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"has_access": has_access,
|
||||||
|
"workspace_rate": serialize_rate(workspace_rate),
|
||||||
|
"project_rate": serialize_rate(project_rate),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_project_access_items(*, workspace: Workspace, target_user) -> list[dict]:
|
||||||
|
explicit_access_ids = {
|
||||||
|
str(project_id)
|
||||||
|
for project_id in ProjectAccess.objects.filter(
|
||||||
|
project__workspace=workspace,
|
||||||
|
user=target_user,
|
||||||
|
).values_list("project_id", flat=True)
|
||||||
|
}
|
||||||
|
workspace_rate = (
|
||||||
|
WorkspaceUserRate.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
user=target_user,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
project_rates: dict[str, ProjectUserRate] = {}
|
||||||
|
for rate in (
|
||||||
|
ProjectUserRate.objects.filter(
|
||||||
|
project__workspace=workspace,
|
||||||
|
user=target_user,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.select_related("project")
|
||||||
|
.order_by("project_id", "-effective_from", "-updated_at")
|
||||||
|
):
|
||||||
|
project_rates.setdefault(str(rate.project_id), rate)
|
||||||
|
projects = (
|
||||||
|
Project.objects.filter(workspace=workspace, is_deleted=False)
|
||||||
|
.select_related("client")
|
||||||
|
.order_by("client__name", "name")
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
build_project_access_item(
|
||||||
|
project=project,
|
||||||
|
has_access=user_has_project_access(target_user, project) if user_has_implicit_project_access(target_user, workspace) else str(project.id) in explicit_access_ids,
|
||||||
|
workspace_rate=workspace_rate,
|
||||||
|
project_rate=project_rates.get(str(project.id)),
|
||||||
|
)
|
||||||
|
for project in projects
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def grant_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
|
||||||
|
ensure_workspace_project_access(actor, workspace)
|
||||||
|
membership = get_project_access_target_membership(workspace, str(target_user.id))
|
||||||
|
if membership.role not in PROJECT_ACCESS_MANAGED_ROLES:
|
||||||
|
raise ValidationError({"user": "Owners and admins already have access to all projects."})
|
||||||
|
|
||||||
|
projects = list(Project.objects.filter(workspace=workspace, id__in=project_ids, is_deleted=False))
|
||||||
|
if len(projects) != len(set(project_ids)):
|
||||||
|
raise ValidationError({"project_ids": "One or more selected projects do not belong to this workspace."})
|
||||||
|
|
||||||
|
changed = 0
|
||||||
|
for project in projects:
|
||||||
|
access, created, restored = ProjectAccess.get_or_restore(project=project, user=target_user)
|
||||||
|
if created or restored:
|
||||||
|
access.is_active = True
|
||||||
|
access.updated_at = timezone.now()
|
||||||
|
access.save(update_fields=["is_active", "updated_at"])
|
||||||
|
changed += 1
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
|
||||||
|
ensure_workspace_project_access(actor, workspace)
|
||||||
|
membership = get_project_access_target_membership(workspace, str(target_user.id))
|
||||||
|
if membership.role not in PROJECT_ACCESS_MANAGED_ROLES:
|
||||||
|
raise ValidationError({"user": "Owners and admins always keep project access."})
|
||||||
|
|
||||||
|
accesses = list(
|
||||||
|
ProjectAccess.objects.filter(
|
||||||
|
project__workspace=workspace,
|
||||||
|
user=target_user,
|
||||||
|
project_id__in=project_ids,
|
||||||
|
).select_related("project")
|
||||||
|
)
|
||||||
|
changed = 0
|
||||||
|
for access in accesses:
|
||||||
|
access.delete()
|
||||||
|
changed += 1
|
||||||
|
return changed
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
from rest_framework.exceptions import ValidationError
|
|
||||||
from apps.projects.models import ProjectMembership
|
|
||||||
|
|
||||||
def add_project_member(project, user_id, role):
|
|
||||||
"""
|
|
||||||
Adds a user to a project. Ensures no duplicate active memberships exist.
|
|
||||||
"""
|
|
||||||
if ProjectMembership.objects.filter(project=project, user_id=user_id, is_deleted=False).exists():
|
|
||||||
raise ValidationError({"user_id": "This user is already a member of the project."})
|
|
||||||
|
|
||||||
return ProjectMembership.objects.create(
|
|
||||||
project=project,
|
|
||||||
user_id=user_id,
|
|
||||||
role=role,
|
|
||||||
is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_project_member(membership, **kwargs):
|
|
||||||
"""
|
|
||||||
Updates a project membership (e.g., changing role or active status).
|
|
||||||
"""
|
|
||||||
update_fields = []
|
|
||||||
for field, value in kwargs.items():
|
|
||||||
if hasattr(membership, field) and getattr(membership, field) != value:
|
|
||||||
setattr(membership, field, value)
|
|
||||||
update_fields.append(field)
|
|
||||||
|
|
||||||
if update_fields:
|
|
||||||
update_fields.append("updated_at")
|
|
||||||
membership.save(update_fields=update_fields)
|
|
||||||
|
|
||||||
return membership
|
|
||||||
@@ -3,15 +3,14 @@ from django.shortcuts import get_object_or_404
|
|||||||
from rest_framework.exceptions import ValidationError, PermissionDenied
|
from rest_framework.exceptions import ValidationError, PermissionDenied
|
||||||
|
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import Project, ProjectMembership
|
from apps.projects.models import Project
|
||||||
from apps.workspaces.models import WorkspaceMembership
|
from apps.workspaces.models import WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create_project(user, workspace, name, client=None, description="", color=""):
|
def create_project(user, workspace, name, client=None, description="", color="", thumbnail=None):
|
||||||
"""
|
"""
|
||||||
Creates a new project and automatically assigns the creator
|
Creates a new workspace-shared project.
|
||||||
as an active MANAGER of that project.
|
|
||||||
"""
|
"""
|
||||||
workspace_member = WorkspaceMembership.objects.filter(
|
workspace_member = WorkspaceMembership.objects.filter(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
@@ -31,18 +30,12 @@ def create_project(user, workspace, name, client=None, description="", color="")
|
|||||||
name=name,
|
name=name,
|
||||||
client=client,
|
client=client,
|
||||||
description=description,
|
description=description,
|
||||||
|
thumbnail=thumbnail,
|
||||||
color=color,
|
color=color,
|
||||||
created_by=user,
|
created_by=user,
|
||||||
updated_by=user,
|
updated_by=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
ProjectMembership.objects.create(
|
|
||||||
project=project,
|
|
||||||
user=user,
|
|
||||||
role=ProjectMembership.Role.MANAGER,
|
|
||||||
is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
@@ -57,10 +50,18 @@ def update_project(project, **kwargs):
|
|||||||
if Project.objects.filter(workspace=project.workspace, name=kwargs["name"], is_deleted=False).exists():
|
if Project.objects.filter(workspace=project.workspace, name=kwargs["name"], is_deleted=False).exists():
|
||||||
raise ValidationError({"name": "A project with this name already exists in the workspace."})
|
raise ValidationError({"name": "A project with this name already exists in the workspace."})
|
||||||
|
|
||||||
|
if "client" in kwargs:
|
||||||
client_id = kwargs.pop("client")
|
client_id = kwargs.pop("client")
|
||||||
client = get_object_or_404(Client, id=client_id, is_deleted=False) if client_id else None
|
client = get_object_or_404(Client, id=client_id, is_deleted=False) if client_id else None
|
||||||
kwargs["client"] = client
|
kwargs["client"] = client
|
||||||
|
|
||||||
|
clear_thumbnail = kwargs.pop("clear_thumbnail", False)
|
||||||
|
old_thumbnail_name = project.thumbnail.name if project.thumbnail else None
|
||||||
|
if clear_thumbnail and project.thumbnail:
|
||||||
|
project.thumbnail.delete(save=False)
|
||||||
|
project.thumbnail = None
|
||||||
|
update_fields.append("thumbnail")
|
||||||
|
|
||||||
for field, value in kwargs.items():
|
for field, value in kwargs.items():
|
||||||
if hasattr(project, field) and getattr(project, field) != value:
|
if hasattr(project, field) and getattr(project, field) != value:
|
||||||
setattr(project, field, value)
|
setattr(project, field, value)
|
||||||
@@ -69,6 +70,10 @@ def update_project(project, **kwargs):
|
|||||||
if update_fields:
|
if update_fields:
|
||||||
update_fields.append("updated_at")
|
update_fields.append("updated_at")
|
||||||
project.save(update_fields=update_fields)
|
project.save(update_fields=update_fields)
|
||||||
|
if old_thumbnail_name and project.thumbnail and project.thumbnail.name != old_thumbnail_name:
|
||||||
|
storage = project.thumbnail.storage
|
||||||
|
if storage.exists(old_thumbnail_name):
|
||||||
|
storage.delete(old_thumbnail_name)
|
||||||
|
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
|||||||
108
apps/projects/services/rates.py
Normal file
108
apps/projects/services/rates.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.projects.models import ProjectUserRate
|
||||||
|
from apps.workspaces.models import HourlyRateHistory
|
||||||
|
|
||||||
|
|
||||||
|
def record_project_rate_history(*, project, user, hourly_rate, currency, effective_from=None):
|
||||||
|
currency = currency.upper()
|
||||||
|
effective_from = effective_from or timezone.now()
|
||||||
|
latest = (
|
||||||
|
HourlyRateHistory.objects.filter(
|
||||||
|
workspace=project.workspace,
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
scope=HourlyRateHistory.Scope.PROJECT,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.order_by("-effective_from", "-created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if latest and latest.hourly_rate == hourly_rate and latest.currency == currency:
|
||||||
|
return latest
|
||||||
|
return HourlyRateHistory.objects.create(
|
||||||
|
workspace=project.workspace,
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
scope=HourlyRateHistory.Scope.PROJECT,
|
||||||
|
hourly_rate=hourly_rate,
|
||||||
|
currency=currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_project_user_rate(*, project, user):
|
||||||
|
return (
|
||||||
|
ProjectUserRate.objects.filter(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_project_user_rate(*, project, user, hourly_rate, currency="USD"):
|
||||||
|
currency = currency.upper()
|
||||||
|
rate = (
|
||||||
|
ProjectUserRate.all_objects.filter(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
.order_by("-updated_at", "-created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
effective_from = timezone.now()
|
||||||
|
if rate:
|
||||||
|
update_fields = []
|
||||||
|
if rate.is_deleted:
|
||||||
|
rate.restore()
|
||||||
|
if rate.hourly_rate != hourly_rate:
|
||||||
|
rate.hourly_rate = hourly_rate
|
||||||
|
update_fields.append("hourly_rate")
|
||||||
|
if rate.currency != currency:
|
||||||
|
rate.currency = currency
|
||||||
|
update_fields.append("currency")
|
||||||
|
if not rate.is_active:
|
||||||
|
rate.is_active = True
|
||||||
|
update_fields.append("is_active")
|
||||||
|
if update_fields:
|
||||||
|
update_fields.append("updated_at")
|
||||||
|
rate.save(update_fields=update_fields)
|
||||||
|
record_project_rate_history(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
)
|
||||||
|
return rate
|
||||||
|
|
||||||
|
rate = ProjectUserRate.objects.create(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
hourly_rate=hourly_rate,
|
||||||
|
currency=currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
record_project_rate_history(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=rate.effective_from,
|
||||||
|
)
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
|
def remove_project_user_rate(*, project, user):
|
||||||
|
rate = get_current_project_user_rate(project=project, user=user)
|
||||||
|
if not rate:
|
||||||
|
return False
|
||||||
|
rate.delete()
|
||||||
|
return True
|
||||||
1
apps/projects/tests/__init__.py
Normal file
1
apps/projects/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
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)
|
||||||
219
apps/projects/tests/test_views.py
Normal file
219
apps/projects/tests/test_views.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from apps.clients.models import Client
|
||||||
|
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
|
||||||
|
from apps.users.models import User
|
||||||
|
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectViewTests(APITestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner = User.objects.create_user(
|
||||||
|
mobile="09121110001",
|
||||||
|
password="secret123",
|
||||||
|
first_name="Owner",
|
||||||
|
)
|
||||||
|
cls.workspace = Workspace.objects.create(name="Projects", owner=cls.owner)
|
||||||
|
PriceUnit.objects.create(code="USD", name="US Dollar", local_name="Dollar", symbol="$")
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
cls.second_project = Project.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
client=cls.second_client,
|
||||||
|
name="Beta",
|
||||||
|
)
|
||||||
|
cls.third_project = Project.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
client=cls.third_client,
|
||||||
|
name="Gamma",
|
||||||
|
)
|
||||||
|
cls.first_project = Project.objects.get(name="Alpha")
|
||||||
|
ProjectAccess.objects.create(project=cls.first_project, user=cls.member)
|
||||||
|
ProjectAccess.objects.create(project=cls.second_project, user=cls.member)
|
||||||
|
WorkspaceUserRate.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.member,
|
||||||
|
hourly_rate=Decimal("25.00"),
|
||||||
|
currency="USD",
|
||||||
|
effective_from=cls.workspace.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_project_list_supports_multi_client_filter(self):
|
||||||
|
self.client.force_authenticate(user=self.member)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/projects/",
|
||||||
|
[
|
||||||
|
("workspace", str(self.workspace.id)),
|
||||||
|
("clients", str(self.first_client.id)),
|
||||||
|
("clients", str(self.second_client.id)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
items = (
|
||||||
|
response.data
|
||||||
|
if isinstance(response.data, list)
|
||||||
|
else response.data.get("results") or response.data.get("items", [])
|
||||||
|
)
|
||||||
|
result_ids = {str(item["client"]["id"]) for item in items}
|
||||||
|
self.assertEqual(
|
||||||
|
result_ids,
|
||||||
|
{str(self.first_client.id), str(self.second_client.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_project_access_list_and_mutations_require_explicit_member_access(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
access_response = self.client.get(
|
||||||
|
"/api/projects/access/",
|
||||||
|
{"workspace": str(self.workspace.id), "user": str(self.member.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(access_response.status_code, 200)
|
||||||
|
items = access_response.data["items"]
|
||||||
|
gamma_item = next(item for item in items if item["id"] == str(self.third_project.id))
|
||||||
|
self.assertFalse(gamma_item["has_access"])
|
||||||
|
alpha_item = next(item for item in items if item["id"] == str(self.first_project.id))
|
||||||
|
self.assertEqual(alpha_item["workspace_rate"]["hourly_rate"], "25.00")
|
||||||
|
self.assertIsNone(alpha_item["project_rate"])
|
||||||
|
|
||||||
|
grant_response = self.client.post(
|
||||||
|
"/api/projects/access/grant/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"user": str(self.member.id),
|
||||||
|
"project_ids": [str(self.third_project.id)],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(grant_response.status_code, 200)
|
||||||
|
|
||||||
|
access_response = self.client.get(
|
||||||
|
"/api/projects/access/",
|
||||||
|
{"workspace": str(self.workspace.id), "user": str(self.member.id)},
|
||||||
|
)
|
||||||
|
gamma_item = next(item for item in access_response.data["items"] if item["id"] == str(self.third_project.id))
|
||||||
|
self.assertTrue(gamma_item["has_access"])
|
||||||
|
|
||||||
|
revoke_response = self.client.post(
|
||||||
|
"/api/projects/access/revoke/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"user": str(self.member.id),
|
||||||
|
"project_ids": [str(self.first_project.id)],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(revoke_response.status_code, 200)
|
||||||
|
self.assertFalse(ProjectAccess.objects.filter(project=self.first_project, user=self.member).exists())
|
||||||
|
|
||||||
|
def test_project_access_rate_endpoint_saves_override_and_keeps_it_dormant_after_revoke(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
save_response = self.client.post(
|
||||||
|
"/api/projects/access/rate/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"user": str(self.member.id),
|
||||||
|
"project": str(self.first_project.id),
|
||||||
|
"hourly_rate": "44.50",
|
||||||
|
"currency": "USD",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(save_response.status_code, 200)
|
||||||
|
self.assertFalse(save_response.data["removed"])
|
||||||
|
self.assertEqual(save_response.data["item"]["project_rate"]["hourly_rate"], "44.50")
|
||||||
|
self.assertTrue(
|
||||||
|
ProjectUserRate.objects.filter(project=self.first_project, user=self.member, is_deleted=False).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
revoke_response = self.client.post(
|
||||||
|
"/api/projects/access/revoke/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"user": str(self.member.id),
|
||||||
|
"project_ids": [str(self.first_project.id)],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(revoke_response.status_code, 200)
|
||||||
|
self.assertTrue(
|
||||||
|
ProjectUserRate.objects.filter(project=self.first_project, user=self.member, is_deleted=False).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
access_response = self.client.get(
|
||||||
|
"/api/projects/access/",
|
||||||
|
{"workspace": str(self.workspace.id), "user": str(self.member.id)},
|
||||||
|
)
|
||||||
|
self.assertEqual(access_response.status_code, 200)
|
||||||
|
alpha_item = next(item for item in access_response.data["items"] if item["id"] == str(self.first_project.id))
|
||||||
|
self.assertFalse(alpha_item["has_access"])
|
||||||
|
self.assertEqual(alpha_item["project_rate"]["hourly_rate"], "44.50")
|
||||||
|
|
||||||
|
def test_project_access_rate_endpoint_rejects_projects_without_access(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/projects/access/rate/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"user": str(self.member.id),
|
||||||
|
"project": str(self.third_project.id),
|
||||||
|
"hourly_rate": "44.50",
|
||||||
|
"currency": "USD",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertIn("Grant project access", response.data["detail"])
|
||||||
|
|
||||||
|
def test_owner_access_state_marks_all_projects_as_accessible_and_allows_project_rate_override(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
access_response = self.client.get(
|
||||||
|
"/api/projects/access/",
|
||||||
|
{"workspace": str(self.workspace.id), "user": str(self.owner.id)},
|
||||||
|
)
|
||||||
|
self.assertEqual(access_response.status_code, 200)
|
||||||
|
self.assertTrue(all(item["has_access"] for item in access_response.data["items"]))
|
||||||
|
|
||||||
|
save_response = self.client.post(
|
||||||
|
"/api/projects/access/rate/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"user": str(self.owner.id),
|
||||||
|
"project": str(self.first_project.id),
|
||||||
|
"hourly_rate": "60.00",
|
||||||
|
"currency": "USD",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(save_response.status_code, 200)
|
||||||
|
self.assertEqual(save_response.data["item"]["project_rate"]["hourly_rate"], "60.00")
|
||||||
|
self.assertTrue(ProjectUserRate.objects.filter(project=self.first_project, user=self.owner, is_deleted=False).exists())
|
||||||
@@ -6,6 +6,7 @@ from apps.reports.api.views import (
|
|||||||
ReportDayDetailsView,
|
ReportDayDetailsView,
|
||||||
ReportExportJobViewSet,
|
ReportExportJobViewSet,
|
||||||
ReportTableView,
|
ReportTableView,
|
||||||
|
ReportUserSummaryView,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -15,6 +16,6 @@ urlpatterns = [
|
|||||||
path("chart/", ReportChartView.as_view(), name="report-chart"),
|
path("chart/", ReportChartView.as_view(), name="report-chart"),
|
||||||
path("table/", ReportTableView.as_view(), name="report-table"),
|
path("table/", ReportTableView.as_view(), name="report-table"),
|
||||||
path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"),
|
path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"),
|
||||||
|
path("user-summary/", ReportUserSummaryView.as_view(), name="report-user-summary"),
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,14 @@ from apps.reports.services import (
|
|||||||
build_chart_report,
|
build_chart_report,
|
||||||
build_day_details_report,
|
build_day_details_report,
|
||||||
build_table_report,
|
build_table_report,
|
||||||
|
build_user_summary_report,
|
||||||
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 +35,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 +53,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 +71,35 @@ 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 ReportUserSummaryView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(responses=dict)
|
||||||
|
def get(self, request):
|
||||||
|
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_user_summary_report(request.user, request.query_params),
|
||||||
|
resource="user-summary",
|
||||||
|
user_id=request.user.id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
params=request.query_params,
|
||||||
|
)
|
||||||
|
return Response(payload)
|
||||||
|
|
||||||
|
|
||||||
class ReportExportJobViewSet(
|
class ReportExportJobViewSet(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from apps.reports.services.aggregation import (
|
|||||||
build_chart_report,
|
build_chart_report,
|
||||||
build_day_details_report,
|
build_day_details_report,
|
||||||
build_table_report,
|
build_table_report,
|
||||||
|
build_user_summary_report,
|
||||||
build_user_scoped_table_reports,
|
build_user_scoped_table_reports,
|
||||||
load_report_filters,
|
load_report_filters,
|
||||||
)
|
)
|
||||||
@@ -10,6 +11,7 @@ __all__ = [
|
|||||||
"load_report_filters",
|
"load_report_filters",
|
||||||
"build_chart_report",
|
"build_chart_report",
|
||||||
"build_table_report",
|
"build_table_report",
|
||||||
|
"build_user_summary_report",
|
||||||
"build_user_scoped_table_reports",
|
"build_user_scoped_table_reports",
|
||||||
"build_day_details_report",
|
"build_day_details_report",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import date, datetime, time, timedelta
|
from datetime import date, datetime, time, timedelta
|
||||||
from decimal import Decimal
|
from decimal import ROUND_DOWN, Decimal
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
import jdatetime
|
import jdatetime
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@@ -15,9 +15,10 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
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.projects.services.access import user_has_project_access
|
||||||
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
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import HourlyRateHistory, Workspace
|
||||||
from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability
|
from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -38,6 +39,25 @@ ALLOWED_PERIODS = {
|
|||||||
PERIOD_CUSTOM,
|
PERIOD_CUSTOM,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UNCATEGORIZED_IDS = {
|
||||||
|
"clients": "__uncategorized_client__",
|
||||||
|
"projects": "__uncategorized_project__",
|
||||||
|
"tags": "__uncategorized_tag__",
|
||||||
|
}
|
||||||
|
|
||||||
|
UNCATEGORIZED_LABELS = {
|
||||||
|
"en": {
|
||||||
|
"clients": "No client",
|
||||||
|
"projects": "No project",
|
||||||
|
"tags": "No tag",
|
||||||
|
},
|
||||||
|
"fa": {
|
||||||
|
"clients": "بدون مشتری",
|
||||||
|
"projects": "بدون پروژه",
|
||||||
|
"tags": "بدون تگ",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _start_of_week(local_date: date) -> date:
|
def _start_of_week(local_date: date) -> date:
|
||||||
days_since_sunday = (local_date.weekday() + 1) % 7
|
days_since_sunday = (local_date.weekday() + 1) % 7
|
||||||
@@ -81,6 +101,374 @@ def _serialize_money_totals(values: dict[str, Decimal]) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_rate(amount: Decimal | None, currency: str | None) -> dict | None:
|
||||||
|
if amount is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"amount": f"{Decimal(amount).quantize(Decimal('0.01'))}",
|
||||||
|
"currency": currency or "USD",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_distinct_rates_from_rows(rate_rows: list[dict]) -> list[dict]:
|
||||||
|
unique_rates: set[tuple[str, str]] = set()
|
||||||
|
for row in rate_rows:
|
||||||
|
unique_rates.add((row["amount"], row["currency"]))
|
||||||
|
return [
|
||||||
|
{"amount": amount, "currency": currency}
|
||||||
|
for amount, currency in sorted(unique_rates, key=lambda item: (item[1], Decimal(item[0])))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_rate_history_rows(*, user, workspace: Workspace, from_date: date, to_date: date) -> list[dict]:
|
||||||
|
current_timezone = timezone.get_current_timezone()
|
||||||
|
period_start = timezone.make_aware(datetime.combine(from_date, time.min), current_timezone)
|
||||||
|
period_end = timezone.make_aware(datetime.combine(to_date + timedelta(days=1), time.min), current_timezone)
|
||||||
|
rows = list(
|
||||||
|
HourlyRateHistory.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
is_deleted=False,
|
||||||
|
effective_from__lt=period_end,
|
||||||
|
)
|
||||||
|
.select_related("project")
|
||||||
|
.order_by("scope", "project_id", "effective_from", "created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
grouped: dict[tuple[str, str | None], list[HourlyRateHistory]] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
grouped[(row.scope, str(row.project_id) if row.project_id else None)].append(row)
|
||||||
|
|
||||||
|
serialized: list[dict] = []
|
||||||
|
for (_scope, _project_id), history_rows in grouped.items():
|
||||||
|
selected_indexes = {
|
||||||
|
index for index, row in enumerate(history_rows) if row.effective_from >= period_start
|
||||||
|
}
|
||||||
|
previous_indexes = [
|
||||||
|
index for index, row in enumerate(history_rows) if row.effective_from < period_start
|
||||||
|
]
|
||||||
|
if previous_indexes:
|
||||||
|
selected_indexes.add(previous_indexes[-1])
|
||||||
|
|
||||||
|
for index in sorted(selected_indexes):
|
||||||
|
row = history_rows[index]
|
||||||
|
next_row = history_rows[index + 1] if index + 1 < len(history_rows) else None
|
||||||
|
if next_row and next_row.effective_from < period_start:
|
||||||
|
continue
|
||||||
|
from_day = max(_localize_datetime(row.effective_from).date(), from_date)
|
||||||
|
to_day = min(_localize_datetime(next_row.effective_from).date(), to_date) if next_row else None
|
||||||
|
serialized.append(
|
||||||
|
{
|
||||||
|
"amount": f"{Decimal(row.hourly_rate).quantize(Decimal('0.01'))}",
|
||||||
|
"currency": row.currency or "USD",
|
||||||
|
"from_date": from_day.isoformat(),
|
||||||
|
"to_date": to_day.isoformat() if to_day else None,
|
||||||
|
"scope": row.scope,
|
||||||
|
"project_name": row.project.name if row.project else None,
|
||||||
|
"is_current": next_row is None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return sorted(
|
||||||
|
serialized,
|
||||||
|
key=lambda item: (
|
||||||
|
item["from_date"],
|
||||||
|
item["scope"],
|
||||||
|
item.get("project_name") or "",
|
||||||
|
Decimal(item["amount"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _uncategorized_label(kind: str, language: str) -> str:
|
||||||
|
if language == "fa":
|
||||||
|
return {
|
||||||
|
"clients": "بدون مشتری",
|
||||||
|
"projects": "بدون پروژه",
|
||||||
|
"tags": "بدون تگ",
|
||||||
|
}[kind]
|
||||||
|
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
|
||||||
|
return UNCATEGORIZED_LABELS[resolved_language][kind]
|
||||||
|
|
||||||
|
|
||||||
|
def _share_bucket(bucket_id: str, name: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": bucket_id,
|
||||||
|
"name": name,
|
||||||
|
"seconds": Decimal("0"),
|
||||||
|
"income": _money_map(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_income_payload(entry: TimeEntry) -> tuple[str, Decimal] | None:
|
||||||
|
if not entry.is_billable or not entry.hourly_rate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
duration_seconds = get_entry_duration_seconds(entry)
|
||||||
|
if duration_seconds <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
hourly_rate = Decimal(entry.hourly_rate)
|
||||||
|
income = (hourly_rate * Decimal(duration_seconds) / Decimal(3600)).quantize(Decimal("0.01"))
|
||||||
|
return entry.currency or "USD", income
|
||||||
|
|
||||||
|
|
||||||
|
def _add_money(bucket: defaultdict[str, Decimal], currency: str, amount: Decimal) -> None:
|
||||||
|
bucket[currency] += amount
|
||||||
|
|
||||||
|
|
||||||
|
def _breakdown_targets(entry: TimeEntry, kind: str, language: str) -> list[tuple[str, str]]:
|
||||||
|
if kind == "clients":
|
||||||
|
if entry.project and entry.project.client:
|
||||||
|
return [(str(entry.project.client_id), entry.project.client.name)]
|
||||||
|
return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))]
|
||||||
|
|
||||||
|
if kind == "projects":
|
||||||
|
if entry.project:
|
||||||
|
return [(str(entry.project_id), entry.project.name)]
|
||||||
|
return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))]
|
||||||
|
|
||||||
|
tags = list(entry.tags.all())
|
||||||
|
if tags:
|
||||||
|
return [(str(tag.id), tag.name) for tag in tags]
|
||||||
|
return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))]
|
||||||
|
|
||||||
|
|
||||||
|
def _accumulate_breakdown_shares(entries: list[TimeEntry], kind: str, *, language: str) -> dict[str, dict]:
|
||||||
|
shares: dict[str, dict] = {}
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.is_billable:
|
||||||
|
continue
|
||||||
|
|
||||||
|
duration_seconds = get_entry_duration_seconds(entry)
|
||||||
|
if duration_seconds <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
targets = _breakdown_targets(entry, kind, language)
|
||||||
|
divisor = Decimal(len(targets)) if kind == "tags" and targets else Decimal("1")
|
||||||
|
income_payload = _entry_income_payload(entry)
|
||||||
|
|
||||||
|
for bucket_id, bucket_name in targets:
|
||||||
|
bucket = shares.setdefault(bucket_id, _share_bucket(bucket_id, bucket_name))
|
||||||
|
bucket["seconds"] += Decimal(duration_seconds) / divisor
|
||||||
|
if income_payload:
|
||||||
|
currency, amount = income_payload
|
||||||
|
_add_money(bucket["income"], currency, amount / divisor)
|
||||||
|
|
||||||
|
return shares
|
||||||
|
|
||||||
|
|
||||||
|
def _allocate_percentage_rows(items: list[dict], total_value: Decimal) -> list[dict]:
|
||||||
|
if total_value <= 0 or not items:
|
||||||
|
return []
|
||||||
|
|
||||||
|
working_rows: list[dict] = []
|
||||||
|
assigned_total = 0
|
||||||
|
for item in items:
|
||||||
|
value = Decimal(item["value"])
|
||||||
|
raw_percentage = (value * Decimal("100") / total_value) if value > 0 else Decimal("0")
|
||||||
|
floored_percentage = int(raw_percentage.quantize(Decimal("1"), rounding=ROUND_DOWN))
|
||||||
|
assigned_total += floored_percentage
|
||||||
|
working_rows.append(
|
||||||
|
{
|
||||||
|
"id": item["id"],
|
||||||
|
"name": item["name"],
|
||||||
|
"value": value,
|
||||||
|
"percentage": floored_percentage,
|
||||||
|
"remainder": raw_percentage - Decimal(floored_percentage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining_points = max(0, 100 - assigned_total)
|
||||||
|
for row in sorted(
|
||||||
|
working_rows,
|
||||||
|
key=lambda item: (-item["remainder"], -item["value"], item["name"].lower(), item["id"]),
|
||||||
|
)[:remaining_points]:
|
||||||
|
row["percentage"] += 1
|
||||||
|
|
||||||
|
serialized = [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"percentage": str(row["percentage"]),
|
||||||
|
}
|
||||||
|
for row in working_rows
|
||||||
|
]
|
||||||
|
serialized.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower()))
|
||||||
|
return serialized
|
||||||
|
|
||||||
|
|
||||||
|
def _single_currency_amount(income_totals: list[dict]) -> tuple[str | None, Decimal] | None:
|
||||||
|
non_zero_totals: list[tuple[str, Decimal]] = []
|
||||||
|
for item in income_totals:
|
||||||
|
amount = Decimal(item["amount"])
|
||||||
|
if amount == 0:
|
||||||
|
continue
|
||||||
|
non_zero_totals.append((item["currency"], amount))
|
||||||
|
|
||||||
|
if not non_zero_totals:
|
||||||
|
return None, Decimal("0")
|
||||||
|
|
||||||
|
currencies = {currency for currency, _ in non_zero_totals}
|
||||||
|
if len(currencies) != 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
currency = non_zero_totals[0][0]
|
||||||
|
total_amount = sum((amount for _, amount in non_zero_totals), Decimal("0"))
|
||||||
|
return currency, total_amount
|
||||||
|
|
||||||
|
|
||||||
|
def _complete_percentage_rows(
|
||||||
|
rows: list[dict],
|
||||||
|
percentage_rows: list[dict],
|
||||||
|
*,
|
||||||
|
unavailable: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
if unavailable:
|
||||||
|
return []
|
||||||
|
|
||||||
|
existing_ids = {row["id"] for row in percentage_rows}
|
||||||
|
completed = percentage_rows + [
|
||||||
|
{"id": row["id"], "name": row["name"], "percentage": "0"}
|
||||||
|
for row in rows
|
||||||
|
if row["id"] not in existing_ids
|
||||||
|
]
|
||||||
|
completed.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower()))
|
||||||
|
return completed
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_time_percentage_rows(rows: list[dict], shares: dict[str, dict]) -> list[dict]:
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"id": bucket["id"],
|
||||||
|
"name": bucket["name"],
|
||||||
|
"value": Decimal(bucket["seconds"]),
|
||||||
|
}
|
||||||
|
for bucket in shares.values()
|
||||||
|
]
|
||||||
|
total_seconds = sum((item["value"] for item in items), Decimal("0"))
|
||||||
|
return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_seconds))
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_income_percentage_rows(rows: list[dict], shares: dict[str, dict]) -> list[dict]:
|
||||||
|
items: list[dict] = []
|
||||||
|
currencies: set[str] = set()
|
||||||
|
|
||||||
|
for bucket in shares.values():
|
||||||
|
income_totals = _serialize_money_totals(bucket["income"])
|
||||||
|
currency_amount = _single_currency_amount(income_totals)
|
||||||
|
if currency_amount is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
currency, amount = currency_amount
|
||||||
|
if currency:
|
||||||
|
currencies.add(currency)
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": bucket["id"],
|
||||||
|
"name": bucket["name"],
|
||||||
|
"value": amount,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(currencies) > 1:
|
||||||
|
return []
|
||||||
|
|
||||||
|
total_income = sum((item["value"] for item in items), Decimal("0"))
|
||||||
|
if total_income <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_income))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user_summary(
|
||||||
|
user,
|
||||||
|
entries: list[TimeEntry],
|
||||||
|
*,
|
||||||
|
workspace: Workspace,
|
||||||
|
from_date: date,
|
||||||
|
to_date: date,
|
||||||
|
language: str,
|
||||||
|
) -> dict:
|
||||||
|
summary = _summary_from_entries(entries)
|
||||||
|
rate_rows = _serialize_rate_history_rows(
|
||||||
|
user=user,
|
||||||
|
workspace=workspace,
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date,
|
||||||
|
)
|
||||||
|
project_rows = _build_breakdown(entries, "projects", language=language)
|
||||||
|
client_rows = _build_breakdown(entries, "clients", language=language)
|
||||||
|
tag_rows = _build_breakdown(entries, "tags", language=language)
|
||||||
|
project_shares = _accumulate_breakdown_shares(entries, "projects", language=language)
|
||||||
|
client_shares = _accumulate_breakdown_shares(entries, "clients", language=language)
|
||||||
|
tag_shares = _accumulate_breakdown_shares(entries, "tags", language=language)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user": {
|
||||||
|
"id": str(user.id),
|
||||||
|
"name": _user_display(user),
|
||||||
|
"mobile": user.mobile,
|
||||||
|
},
|
||||||
|
"hourly_rates": _serialize_distinct_rates_from_rows(rate_rows),
|
||||||
|
"rate_periods": rate_rows,
|
||||||
|
"total_seconds": summary["billable_seconds"],
|
||||||
|
"total_duration": summary["total_duration"],
|
||||||
|
"billable_seconds": summary["billable_seconds"],
|
||||||
|
"billable_duration": summary["billable_duration"],
|
||||||
|
"non_billable_seconds": summary["non_billable_seconds"],
|
||||||
|
"non_billable_duration": summary["non_billable_duration"],
|
||||||
|
"income_totals": summary["income_totals"],
|
||||||
|
"project_percentages": _serialize_time_percentage_rows(project_rows, project_shares),
|
||||||
|
"client_percentages": _serialize_time_percentage_rows(client_rows, client_shares),
|
||||||
|
"tag_percentages": _serialize_time_percentage_rows(tag_rows, tag_shares),
|
||||||
|
"project_income_percentages": _serialize_income_percentage_rows(project_rows, project_shares),
|
||||||
|
"client_income_percentages": _serialize_income_percentage_rows(client_rows, client_shares),
|
||||||
|
"tag_income_percentages": _serialize_income_percentage_rows(tag_rows, tag_shares),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user_summaries(entries: list[TimeEntry], *, filters: ReportFilters) -> list[dict]:
|
||||||
|
grouped: dict[str, list[TimeEntry]] = defaultdict(list)
|
||||||
|
for entry in entries:
|
||||||
|
grouped[str(entry.user_id)].append(entry)
|
||||||
|
|
||||||
|
summaries = [
|
||||||
|
_build_user_summary(
|
||||||
|
grouped_entries[0].user,
|
||||||
|
grouped_entries,
|
||||||
|
workspace=filters.workspace,
|
||||||
|
from_date=filters.from_date,
|
||||||
|
to_date=filters.to_date,
|
||||||
|
language=filters.language,
|
||||||
|
)
|
||||||
|
for grouped_entries in grouped.values()
|
||||||
|
if grouped_entries
|
||||||
|
]
|
||||||
|
summaries.sort(key=lambda item: item["user"]["name"].lower())
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
def _build_overall_percentage_payload(
|
||||||
|
entries: list[TimeEntry],
|
||||||
|
*,
|
||||||
|
language: str,
|
||||||
|
rows_by_kind: dict[str, list[dict]],
|
||||||
|
) -> dict:
|
||||||
|
project_shares = _accumulate_breakdown_shares(entries, "projects", language=language)
|
||||||
|
client_shares = _accumulate_breakdown_shares(entries, "clients", language=language)
|
||||||
|
tag_shares = _accumulate_breakdown_shares(entries, "tags", language=language)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project_percentages": _serialize_time_percentage_rows(rows_by_kind["projects"], project_shares),
|
||||||
|
"client_percentages": _serialize_time_percentage_rows(rows_by_kind["clients"], client_shares),
|
||||||
|
"tag_percentages": _serialize_time_percentage_rows(rows_by_kind["tags"], tag_shares),
|
||||||
|
"project_income_percentages": _serialize_income_percentage_rows(rows_by_kind["projects"], project_shares),
|
||||||
|
"client_income_percentages": _serialize_income_percentage_rows(rows_by_kind["clients"], client_shares),
|
||||||
|
"tag_income_percentages": _serialize_income_percentage_rows(rows_by_kind["tags"], tag_shares),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry):
|
def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry):
|
||||||
if not entry.is_billable or not entry.hourly_rate:
|
if not entry.is_billable or not entry.hourly_rate:
|
||||||
return
|
return
|
||||||
@@ -135,7 +523,13 @@ class ReportFilterSerializer(serializers.Serializer):
|
|||||||
language = serializers.ChoiceField(choices=("en", "fa"), required=False, default="en")
|
language = serializers.ChoiceField(choices=("en", "fa"), required=False, default="en")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_period_bounds(period: str, from_date: date | None, to_date: date | None, *, language: str) -> tuple[date, date]:
|
def _resolve_period_bounds(
|
||||||
|
period: str,
|
||||||
|
from_date: date | None,
|
||||||
|
to_date: date | None,
|
||||||
|
*,
|
||||||
|
language: str,
|
||||||
|
) -> tuple[date, date]:
|
||||||
today = timezone.localdate()
|
today = timezone.localdate()
|
||||||
if language == "fa":
|
if language == "fa":
|
||||||
today_jalali = jdatetime.date.fromgregorian(date=today)
|
today_jalali = jdatetime.date.fromgregorian(date=today)
|
||||||
@@ -203,7 +597,11 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
|
|||||||
"user": raw_data.get("user"),
|
"user": raw_data.get("user"),
|
||||||
"client": raw_data.get("client"),
|
"client": raw_data.get("client"),
|
||||||
"project": raw_data.get("project"),
|
"project": raw_data.get("project"),
|
||||||
"tags": raw_data.get("tags") or raw_data.getlist("tags") if hasattr(raw_data, "getlist") else raw_data.get("tags"),
|
"tags": (
|
||||||
|
raw_data.get("tags") or raw_data.getlist("tags")
|
||||||
|
if hasattr(raw_data, "getlist")
|
||||||
|
else raw_data.get("tags")
|
||||||
|
),
|
||||||
"language": raw_data.get("language", "en"),
|
"language": raw_data.get("language", "en"),
|
||||||
}
|
}
|
||||||
if normalized["tags"] and not isinstance(normalized["tags"], list):
|
if normalized["tags"] and not isinstance(normalized["tags"], list):
|
||||||
@@ -242,6 +640,10 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
|
|||||||
raise serializers.ValidationError("Client does not belong to this workspace.")
|
raise serializers.ValidationError("Client does not belong to this workspace.")
|
||||||
if project_id and not Project.objects.filter(id=project_id, workspace=workspace).exists():
|
if project_id and not Project.objects.filter(id=project_id, workspace=workspace).exists():
|
||||||
raise serializers.ValidationError("Project does not belong to this workspace.")
|
raise serializers.ValidationError("Project does not belong to this workspace.")
|
||||||
|
if project_id and not is_workspace_scope:
|
||||||
|
project = Project.objects.filter(id=project_id, workspace=workspace).first()
|
||||||
|
if project and not user_has_project_access(actor, project):
|
||||||
|
raise serializers.ValidationError("Project does not belong to this workspace.")
|
||||||
if tag_ids:
|
if tag_ids:
|
||||||
existing_tag_ids = set(Tag.objects.filter(id__in=tag_ids, workspace=workspace).values_list("id", flat=True))
|
existing_tag_ids = set(Tag.objects.filter(id__in=tag_ids, workspace=workspace).values_list("id", flat=True))
|
||||||
if len(existing_tag_ids) != len(tag_ids):
|
if len(existing_tag_ids) != len(tag_ids):
|
||||||
@@ -263,8 +665,15 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
|
|||||||
|
|
||||||
|
|
||||||
def _base_queryset(filters: ReportFilters) -> QuerySet[TimeEntry]:
|
def _base_queryset(filters: ReportFilters) -> QuerySet[TimeEntry]:
|
||||||
start_dt = timezone.make_aware(datetime.combine(filters.from_date, time.min), timezone.get_current_timezone())
|
current_timezone = timezone.get_current_timezone()
|
||||||
end_dt = timezone.make_aware(datetime.combine(filters.to_date + timedelta(days=1), time.min), timezone.get_current_timezone())
|
start_dt = timezone.make_aware(
|
||||||
|
datetime.combine(filters.from_date, time.min),
|
||||||
|
current_timezone,
|
||||||
|
)
|
||||||
|
end_dt = timezone.make_aware(
|
||||||
|
datetime.combine(filters.to_date + timedelta(days=1), time.min),
|
||||||
|
current_timezone,
|
||||||
|
)
|
||||||
|
|
||||||
queryset = (
|
queryset = (
|
||||||
TimeEntry.objects.filter(
|
TimeEntry.objects.filter(
|
||||||
@@ -367,9 +776,6 @@ def _bucket_key(filters: ReportFilters, local_dt: datetime) -> tuple[str, date]:
|
|||||||
if filters.period in {PERIOD_THIS_WEEK, PERIOD_THIS_MONTH, PERIOD_CUSTOM}:
|
if filters.period in {PERIOD_THIS_WEEK, PERIOD_THIS_MONTH, PERIOD_CUSTOM}:
|
||||||
bucket_date = local_dt.date()
|
bucket_date = local_dt.date()
|
||||||
return bucket_date.isoformat(), bucket_date
|
return bucket_date.isoformat(), bucket_date
|
||||||
if filters.language == "fa":
|
|
||||||
persian_date = jdatetime.date.fromgregorian(date=local_dt.date())
|
|
||||||
return f"{persian_date.year:04d}-{persian_date.month:02d}", local_dt.date()
|
|
||||||
bucket_date = date(local_dt.year, local_dt.month, 1)
|
bucket_date = date(local_dt.year, local_dt.month, 1)
|
||||||
return bucket_date.strftime("%Y-%m"), bucket_date
|
return bucket_date.strftime("%Y-%m"), bucket_date
|
||||||
|
|
||||||
@@ -378,9 +784,23 @@ def build_chart_report(actor, raw_filters) -> dict:
|
|||||||
filters = load_report_filters(actor, raw_filters)
|
filters = load_report_filters(actor, raw_filters)
|
||||||
entries = list(_base_queryset(filters))
|
entries = list(_base_queryset(filters))
|
||||||
summary = _summary_from_entries(entries)
|
summary = _summary_from_entries(entries)
|
||||||
buckets: dict[str, dict] = {}
|
grouped_entries: dict[str | None, list[TimeEntry]] = defaultdict(list)
|
||||||
|
if filters.is_workspace_scope and not filters.user_id:
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
|
grouped_entries[str(entry.user_id)].append(entry)
|
||||||
|
else:
|
||||||
|
grouped_entries[filters.user_id] = entries
|
||||||
|
|
||||||
|
serialized_series = []
|
||||||
|
for _, series_entries in sorted(
|
||||||
|
grouped_entries.items(),
|
||||||
|
key=lambda item: _user_display(item[1][0].user).lower() if item[1] else "",
|
||||||
|
):
|
||||||
|
if not series_entries:
|
||||||
|
continue
|
||||||
|
|
||||||
|
buckets: dict[str, dict] = {}
|
||||||
|
for entry in series_entries:
|
||||||
local_start = _localize_datetime(entry.start_time)
|
local_start = _localize_datetime(entry.start_time)
|
||||||
bucket_id, bucket_date = _bucket_key(filters, local_start)
|
bucket_id, bucket_date = _bucket_key(filters, local_start)
|
||||||
bucket = buckets.setdefault(
|
bucket = buckets.setdefault(
|
||||||
@@ -399,10 +819,22 @@ def build_chart_report(actor, raw_filters) -> dict:
|
|||||||
bucket["total_duration"] = _format_duration_seconds(bucket["total_seconds"])
|
bucket["total_duration"] = _format_duration_seconds(bucket["total_seconds"])
|
||||||
serialized_buckets.append(bucket)
|
serialized_buckets.append(bucket)
|
||||||
|
|
||||||
|
user = series_entries[0].user
|
||||||
|
serialized_series.append(
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"id": str(user.id),
|
||||||
|
"name": _user_display(user),
|
||||||
|
"mobile": user.mobile,
|
||||||
|
},
|
||||||
|
"buckets": serialized_buckets,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"scope": _scope_payload(filters),
|
"scope": _scope_payload(filters),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"buckets": serialized_buckets,
|
"series": serialized_series,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -421,7 +853,11 @@ def _scope_payload(filters: ReportFilters) -> dict:
|
|||||||
"workspace": {
|
"workspace": {
|
||||||
"id": str(filters.workspace.id),
|
"id": str(filters.workspace.id),
|
||||||
"name": filters.workspace.name,
|
"name": filters.workspace.name,
|
||||||
"thumbnail_path": filters.workspace.thumbnail.path if getattr(filters.workspace, "thumbnail", None) else None,
|
"thumbnail_path": (
|
||||||
|
filters.workspace.thumbnail.path
|
||||||
|
if getattr(filters.workspace, "thumbnail", None)
|
||||||
|
else None
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"period": filters.period,
|
"period": filters.period,
|
||||||
"from_date": filters.from_date.isoformat(),
|
"from_date": filters.from_date.isoformat(),
|
||||||
@@ -436,19 +872,46 @@ def _scope_payload(filters: ReportFilters) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> dict:
|
def _table_report_payload(
|
||||||
|
filters: ReportFilters,
|
||||||
|
entries: list[TimeEntry],
|
||||||
|
*,
|
||||||
|
user_summary: dict | None = None,
|
||||||
|
user_summaries: list[dict] | None = None,
|
||||||
|
) -> dict:
|
||||||
summary = _summary_from_entries(entries)
|
summary = _summary_from_entries(entries)
|
||||||
return {
|
include_latest_rate = not (filters.is_workspace_scope and not filters.user_id)
|
||||||
|
client_rows = _build_breakdown(entries, "clients", language=filters.language)
|
||||||
|
project_rows = _build_breakdown(entries, "projects", language=filters.language)
|
||||||
|
tag_rows = _build_breakdown(entries, "tags", language=filters.language)
|
||||||
|
payload = {
|
||||||
"scope": _scope_payload(filters),
|
"scope": _scope_payload(filters),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"days": _group_daily(entries),
|
"days": _group_daily(entries, include_latest_rate=include_latest_rate),
|
||||||
"clients": _build_breakdown(entries, "clients"),
|
"clients": client_rows,
|
||||||
"projects": _build_breakdown(entries, "projects"),
|
"projects": project_rows,
|
||||||
"tags": _build_breakdown(entries, "tags"),
|
"tags": tag_rows,
|
||||||
}
|
}
|
||||||
|
if filters.is_workspace_scope and not filters.user_id:
|
||||||
|
payload.update(
|
||||||
|
_build_overall_percentage_payload(
|
||||||
|
entries,
|
||||||
|
language=filters.language,
|
||||||
|
rows_by_kind={
|
||||||
|
"clients": client_rows,
|
||||||
|
"projects": project_rows,
|
||||||
|
"tags": tag_rows,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if user_summary is not None:
|
||||||
|
payload["user_summary"] = user_summary
|
||||||
|
if user_summaries is not None:
|
||||||
|
payload["user_summaries"] = user_summaries
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _group_daily(entries: list[TimeEntry]) -> list[dict]:
|
def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list[dict]:
|
||||||
by_day: dict[str, dict] = {}
|
by_day: dict[str, dict] = {}
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
local_start = _localize_datetime(entry.start_time)
|
local_start = _localize_datetime(entry.start_time)
|
||||||
@@ -461,6 +924,9 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
|
|||||||
"non_billable_seconds": 0,
|
"non_billable_seconds": 0,
|
||||||
"total_seconds": 0,
|
"total_seconds": 0,
|
||||||
"income": _money_map(),
|
"income": _money_map(),
|
||||||
|
"latest_rate_amount": None,
|
||||||
|
"latest_rate_currency": None,
|
||||||
|
"latest_rate_timestamp": None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
duration_seconds = get_entry_duration_seconds(entry)
|
duration_seconds = get_entry_duration_seconds(entry)
|
||||||
@@ -470,6 +936,18 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
|
|||||||
else:
|
else:
|
||||||
day_bucket["non_billable_seconds"] += duration_seconds
|
day_bucket["non_billable_seconds"] += duration_seconds
|
||||||
_add_income(day_bucket["income"], entry)
|
_add_income(day_bucket["income"], entry)
|
||||||
|
if (
|
||||||
|
include_latest_rate
|
||||||
|
and entry.is_billable
|
||||||
|
and entry.hourly_rate
|
||||||
|
and (
|
||||||
|
day_bucket["latest_rate_timestamp"] is None
|
||||||
|
or local_start >= day_bucket["latest_rate_timestamp"]
|
||||||
|
)
|
||||||
|
):
|
||||||
|
day_bucket["latest_rate_amount"] = Decimal(entry.hourly_rate)
|
||||||
|
day_bucket["latest_rate_currency"] = entry.currency or "USD"
|
||||||
|
day_bucket["latest_rate_timestamp"] = local_start
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for day_key in sorted(by_day.keys()):
|
for day_key in sorted(by_day.keys()):
|
||||||
@@ -483,49 +961,21 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
|
|||||||
"billable_duration": _format_duration_seconds(bucket["billable_seconds"]),
|
"billable_duration": _format_duration_seconds(bucket["billable_seconds"]),
|
||||||
"non_billable_duration": _format_duration_seconds(bucket["non_billable_seconds"]),
|
"non_billable_duration": _format_duration_seconds(bucket["non_billable_seconds"]),
|
||||||
"total_duration": _format_duration_seconds(bucket["total_seconds"]),
|
"total_duration": _format_duration_seconds(bucket["total_seconds"]),
|
||||||
|
"latest_hourly_rate": _serialize_rate(
|
||||||
|
bucket["latest_rate_amount"],
|
||||||
|
bucket["latest_rate_currency"],
|
||||||
|
) if include_latest_rate else None,
|
||||||
"income_totals": _serialize_money_totals(bucket["income"]),
|
"income_totals": _serialize_money_totals(bucket["income"]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]:
|
def _build_breakdown(entries: list[TimeEntry], kind: str, *, language: str) -> list[dict]:
|
||||||
data: dict[str, dict] = {}
|
data: dict[str, dict] = {}
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if kind == "clients":
|
|
||||||
if not entry.project or not entry.project.client:
|
|
||||||
continue
|
|
||||||
item_id = str(entry.project.client_id)
|
|
||||||
item_name = entry.project.client.name
|
|
||||||
elif kind == "projects":
|
|
||||||
if not entry.project:
|
|
||||||
continue
|
|
||||||
item_id = str(entry.project_id)
|
|
||||||
item_name = entry.project.name
|
|
||||||
else:
|
|
||||||
if not entry.tags.exists():
|
|
||||||
continue
|
|
||||||
for tag in entry.tags.all():
|
|
||||||
bucket = data.setdefault(
|
|
||||||
str(tag.id),
|
|
||||||
{
|
|
||||||
"id": str(tag.id),
|
|
||||||
"name": tag.name,
|
|
||||||
"billable_seconds": 0,
|
|
||||||
"non_billable_seconds": 0,
|
|
||||||
"total_seconds": 0,
|
|
||||||
"income": _money_map(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
duration_seconds = get_entry_duration_seconds(entry)
|
duration_seconds = get_entry_duration_seconds(entry)
|
||||||
bucket["total_seconds"] += duration_seconds
|
for item_id, item_name in _breakdown_targets(entry, kind, language):
|
||||||
if entry.is_billable:
|
|
||||||
bucket["billable_seconds"] += duration_seconds
|
|
||||||
else:
|
|
||||||
bucket["non_billable_seconds"] += duration_seconds
|
|
||||||
_add_income(bucket["income"], entry)
|
|
||||||
continue
|
|
||||||
|
|
||||||
bucket = data.setdefault(
|
bucket = data.setdefault(
|
||||||
item_id,
|
item_id,
|
||||||
{
|
{
|
||||||
@@ -537,7 +987,6 @@ def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]:
|
|||||||
"income": _money_map(),
|
"income": _money_map(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
duration_seconds = get_entry_duration_seconds(entry)
|
|
||||||
bucket["total_seconds"] += duration_seconds
|
bucket["total_seconds"] += duration_seconds
|
||||||
if entry.is_billable:
|
if entry.is_billable:
|
||||||
bucket["billable_seconds"] += duration_seconds
|
bucket["billable_seconds"] += duration_seconds
|
||||||
@@ -546,7 +995,7 @@ def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]:
|
|||||||
_add_income(bucket["income"], entry)
|
_add_income(bucket["income"], entry)
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for item_id, bucket in sorted(data.items(), key=lambda item: item[1]["name"].lower()):
|
for _item_id, bucket in sorted(data.items(), key=lambda item: item[1]["name"].lower()):
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"id": bucket["id"],
|
"id": bucket["id"],
|
||||||
@@ -566,7 +1015,47 @@ def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]:
|
|||||||
def build_table_report(actor, raw_filters) -> dict:
|
def build_table_report(actor, raw_filters) -> dict:
|
||||||
filters = load_report_filters(actor, raw_filters)
|
filters = load_report_filters(actor, raw_filters)
|
||||||
entries = list(_base_queryset(filters))
|
entries = list(_base_queryset(filters))
|
||||||
return _table_report_payload(filters, entries)
|
if filters.is_workspace_scope and not filters.user_id:
|
||||||
|
payload = _table_report_payload(
|
||||||
|
filters,
|
||||||
|
entries,
|
||||||
|
user_summaries=_build_user_summaries(entries, filters=filters),
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
user_summary = (
|
||||||
|
_build_user_summary(
|
||||||
|
entries[0].user,
|
||||||
|
entries,
|
||||||
|
workspace=filters.workspace,
|
||||||
|
from_date=filters.from_date,
|
||||||
|
to_date=filters.to_date,
|
||||||
|
language=filters.language,
|
||||||
|
)
|
||||||
|
if entries and filters.user_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return _table_report_payload(filters, entries, user_summary=user_summary)
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_summary_report(actor, raw_filters) -> dict:
|
||||||
|
filters = load_report_filters(actor, raw_filters)
|
||||||
|
if not filters.user_id:
|
||||||
|
raise serializers.ValidationError("A user is required.")
|
||||||
|
|
||||||
|
entries = list(_base_queryset(filters))
|
||||||
|
user_summary = (
|
||||||
|
_build_user_summary(
|
||||||
|
entries[0].user,
|
||||||
|
entries,
|
||||||
|
workspace=filters.workspace,
|
||||||
|
from_date=filters.from_date,
|
||||||
|
to_date=filters.to_date,
|
||||||
|
language=filters.language,
|
||||||
|
)
|
||||||
|
if entries
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return _table_report_payload(filters, entries, user_summary=user_summary)
|
||||||
|
|
||||||
|
|
||||||
def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
||||||
@@ -587,7 +1076,20 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
|||||||
reports: list[dict] = []
|
reports: list[dict] = []
|
||||||
for user_id, user_entries in sorted_groups:
|
for user_id, user_entries in sorted_groups:
|
||||||
user_filters = replace(filters, user_id=user_id)
|
user_filters = replace(filters, user_id=user_id)
|
||||||
reports.append(_table_report_payload(user_filters, user_entries))
|
reports.append(
|
||||||
|
_table_report_payload(
|
||||||
|
user_filters,
|
||||||
|
user_entries,
|
||||||
|
user_summary=_build_user_summary(
|
||||||
|
user_entries[0].user,
|
||||||
|
user_entries,
|
||||||
|
workspace=filters.workspace,
|
||||||
|
from_date=filters.from_date,
|
||||||
|
to_date=filters.to_date,
|
||||||
|
language=filters.language,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
return reports
|
return reports
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
import jdatetime
|
import jdatetime
|
||||||
from arabic_reshaper import reshape
|
from arabic_reshaper import reshape
|
||||||
@@ -24,18 +24,39 @@ TRANSLATIONS = {
|
|||||||
"en": {
|
"en": {
|
||||||
"report_title": "Workspace Report",
|
"report_title": "Workspace Report",
|
||||||
"overall_sheet": "Overall Report",
|
"overall_sheet": "Overall Report",
|
||||||
|
"users_summary_sheet": "Users Summary",
|
||||||
"workspace": "Workspace",
|
"workspace": "Workspace",
|
||||||
"period": "Period",
|
"period": "Period",
|
||||||
"from_date": "From date",
|
"from_date": "From date",
|
||||||
"to_date": "To date",
|
"to_date": "To date",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
|
"mobile": "Mobile",
|
||||||
"all_users": "All users",
|
"all_users": "All users",
|
||||||
"generated_at": "Generated at",
|
"generated_at": "Generated at",
|
||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
"total_hours": "Total hours",
|
"total_hours": "Total hours",
|
||||||
"billable_hours": "Billable hours",
|
"billable_hours": "Billable hours",
|
||||||
"non_billable_hours": "Non-billable hours",
|
"non_billable_hours": "Non-billable hours",
|
||||||
|
"hourly_rate": "Hourly rate",
|
||||||
"income": "Income",
|
"income": "Income",
|
||||||
|
"working_hours": "Working hours",
|
||||||
|
"non_working_hours": "Non-working hours",
|
||||||
|
"hourly_rates": "Hourly rates",
|
||||||
|
"project_percentages": "Project percentages",
|
||||||
|
"client_percentages": "Client percentages",
|
||||||
|
"tag_percentages": "Tag percentages",
|
||||||
|
"summary_by_user": "Summary by user",
|
||||||
|
"rate_history": "Hourly rate history",
|
||||||
|
"from": "From",
|
||||||
|
"to": "To",
|
||||||
|
"now": "Now",
|
||||||
|
"project": "Project",
|
||||||
|
"percentage": "Percentage",
|
||||||
|
"hour_percentage": "Hour %",
|
||||||
|
"income_percentage": "Income %",
|
||||||
|
"multiple_rates": "Multiple rates - see details",
|
||||||
|
"variable_rate": "Variable rate",
|
||||||
|
"none": "None",
|
||||||
"daily_summary": "Daily Summary",
|
"daily_summary": "Daily Summary",
|
||||||
"clients": "Clients",
|
"clients": "Clients",
|
||||||
"projects": "Projects",
|
"projects": "Projects",
|
||||||
@@ -44,22 +65,46 @@ TRANSLATIONS = {
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"no_data": "No data",
|
"no_data": "No data",
|
||||||
|
"uncategorized_client": "No client",
|
||||||
|
"uncategorized_project": "No project",
|
||||||
|
"uncategorized_tag": "No tag",
|
||||||
},
|
},
|
||||||
"fa": {
|
"fa": {
|
||||||
"report_title": "گزارش فضای کاری",
|
"report_title": "گزارش فضای کاری",
|
||||||
"overall_sheet": "گزارش کلی",
|
"overall_sheet": "گزارش کلی",
|
||||||
|
"users_summary_sheet": "خلاصه کاربران",
|
||||||
"workspace": "فضای کاری",
|
"workspace": "فضای کاری",
|
||||||
"period": "بازه",
|
"period": "بازه",
|
||||||
"from_date": "از تاریخ",
|
"from_date": "از تاریخ",
|
||||||
"to_date": "تا تاریخ",
|
"to_date": "تا تاریخ",
|
||||||
"user": "کاربر",
|
"user": "کاربر",
|
||||||
|
"mobile": "موبایل",
|
||||||
"all_users": "همه کاربران",
|
"all_users": "همه کاربران",
|
||||||
"generated_at": "تاریخ تولید",
|
"generated_at": "تاریخ تولید",
|
||||||
"summary": "خلاصه",
|
"summary": "خلاصه",
|
||||||
"total_hours": "کل ساعات",
|
"total_hours": "کل ساعات",
|
||||||
"billable_hours": "ساعات کاری",
|
"billable_hours": "ساعات کاری",
|
||||||
"non_billable_hours": "ساعات غیر کاری",
|
"non_billable_hours": "ساعات غیر کاری",
|
||||||
"income": "درآمد",
|
"hourly_rate": "نرخ ساعتی",
|
||||||
|
"income": "کارکرد",
|
||||||
|
"working_hours": "ساعات کاری",
|
||||||
|
"non_working_hours": "ساعات غیرکاری",
|
||||||
|
"hourly_rates": "نرخهای ساعتی",
|
||||||
|
"project_percentages": "درصد پروژهها",
|
||||||
|
"client_percentages": "درصد مشتریها",
|
||||||
|
"tag_percentages": "درصد تگها",
|
||||||
|
"summary_by_user": "خلاصه کاربران",
|
||||||
|
"rate_history": "تاریخچه نرخ ساعتی",
|
||||||
|
"from": "از",
|
||||||
|
"to": "تا",
|
||||||
|
"now": "حال",
|
||||||
|
"project": "پروژه",
|
||||||
|
"percentage": "درصد",
|
||||||
|
"hour_percentage": "درصد ساعت",
|
||||||
|
"income_percentage": "درصد کارکرد",
|
||||||
|
"multiple_rates": "چند نرخ - جزئیات در گزارش کاربر",
|
||||||
|
"variable_rate": "نرخ متغیر",
|
||||||
|
"none": "بدون مورد",
|
||||||
"daily_summary": "خلاصه روزانه",
|
"daily_summary": "خلاصه روزانه",
|
||||||
"clients": "مشتریان",
|
"clients": "مشتریان",
|
||||||
"projects": "پروژهها",
|
"projects": "پروژهها",
|
||||||
@@ -68,6 +113,9 @@ TRANSLATIONS = {
|
|||||||
"name": "نام",
|
"name": "نام",
|
||||||
"total": "جمع",
|
"total": "جمع",
|
||||||
"no_data": "بدون داده",
|
"no_data": "بدون داده",
|
||||||
|
"uncategorized_client": "بدون مشتری",
|
||||||
|
"uncategorized_project": "بدون پروژه",
|
||||||
|
"uncategorized_tag": "بدون تگ",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +148,8 @@ CURRENCY_LABELS = {
|
|||||||
"TRY": {"en": "TRY", "fa": "لیر"},
|
"TRY": {"en": "TRY", "fa": "لیر"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DECIMAL_TRIM_CURRENCIES = {"IRR", "IRT"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ExportLocale:
|
class ExportLocale:
|
||||||
@@ -134,6 +184,15 @@ class ExportLocale:
|
|||||||
return self.format_number(value, ascii_digits=ascii_digits)
|
return self.format_number(value, ascii_digits=ascii_digits)
|
||||||
|
|
||||||
def format_amount(self, value: object, *, ascii_digits: bool = False) -> str:
|
def format_amount(self, value: object, *, ascii_digits: bool = False) -> str:
|
||||||
|
return self.format_amount_for_currency(value, None, ascii_digits=ascii_digits)
|
||||||
|
|
||||||
|
def format_amount_for_currency(
|
||||||
|
self,
|
||||||
|
value: object,
|
||||||
|
currency: str | None,
|
||||||
|
*,
|
||||||
|
ascii_digits: bool = False,
|
||||||
|
) -> str:
|
||||||
raw = str(value).strip()
|
raw = str(value).strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return raw
|
return raw
|
||||||
@@ -149,18 +208,24 @@ class ExportLocale:
|
|||||||
grouped_integer = f"{int(integer_part):,}"
|
grouped_integer = f"{int(integer_part):,}"
|
||||||
formatted = f"{sign}{grouped_integer}"
|
formatted = f"{sign}{grouped_integer}"
|
||||||
if fractional_part:
|
if fractional_part:
|
||||||
trimmed_fraction = fractional_part.rstrip("0")
|
trimmed_fraction = (
|
||||||
|
""
|
||||||
|
if str(currency or "").upper() in DECIMAL_TRIM_CURRENCIES
|
||||||
|
else fractional_part.rstrip("0")
|
||||||
|
)
|
||||||
if trimmed_fraction:
|
if trimmed_fraction:
|
||||||
formatted = f"{formatted}.{trimmed_fraction}"
|
formatted = f"{formatted}.{trimmed_fraction}"
|
||||||
return self.format_number(formatted, ascii_digits=ascii_digits)
|
return self.format_number(formatted, ascii_digits=ascii_digits)
|
||||||
|
|
||||||
def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str:
|
def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str:
|
||||||
if not income_totals:
|
if not income_totals:
|
||||||
return "-"
|
return self.format_number("0", ascii_digits=ascii_digits)
|
||||||
parts = []
|
parts = []
|
||||||
for item in income_totals:
|
for item in income_totals:
|
||||||
currency = self.currency_label(item["currency"])
|
currency = self.currency_label(item["currency"])
|
||||||
parts.append(f"{self.format_amount(item['amount'], ascii_digits=ascii_digits)} {currency}")
|
parts.append(
|
||||||
|
f"{self.format_amount_for_currency(item['amount'], item['currency'], ascii_digits=ascii_digits)} {currency}"
|
||||||
|
)
|
||||||
return " | ".join(parts)
|
return " | ".join(parts)
|
||||||
|
|
||||||
def currency_label(self, code: str | None) -> str:
|
def currency_label(self, code: str | None) -> str:
|
||||||
@@ -195,7 +260,7 @@ def user_label(user_payload: dict | None, locale: ExportLocale, *, ascii_digits:
|
|||||||
|
|
||||||
|
|
||||||
def safe_sheet_title(title: str, used: Iterable[str]) -> str:
|
def safe_sheet_title(title: str, used: Iterable[str]) -> str:
|
||||||
invalid = set('[]:*?/\\')
|
invalid = set("[]:*?/\\")
|
||||||
sanitized = "".join("-" if char in invalid else char for char in title).strip() or "Sheet"
|
sanitized = "".join("-" if char in invalid else char for char in title).strip() or "Sheet"
|
||||||
base = sanitized[:31]
|
base = sanitized[:31]
|
||||||
used_set = set(used)
|
used_set = set(used)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -30,13 +30,13 @@ def generate_report_export_task(job_id: str):
|
|||||||
try:
|
try:
|
||||||
locale = build_export_locale(job.filters.get("language"))
|
locale = build_export_locale(job.filters.get("language"))
|
||||||
report_data = build_table_report(job.requesting_user, job.filters)
|
report_data = build_table_report(job.requesting_user, job.filters)
|
||||||
if job.export_type == ReportExportJob.ExportType.EXCEL:
|
|
||||||
per_user_reports = build_user_scoped_table_reports(job.requesting_user, job.filters)
|
per_user_reports = build_user_scoped_table_reports(job.requesting_user, job.filters)
|
||||||
|
if job.export_type == ReportExportJob.ExportType.EXCEL:
|
||||||
content = build_excel_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
|
content = build_excel_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
|
||||||
suffix = "xlsx"
|
suffix = "xlsx"
|
||||||
mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
else:
|
else:
|
||||||
content = build_pdf_report(report_data=report_data, locale=locale)
|
content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
|
||||||
suffix = "pdf"
|
suffix = "pdf"
|
||||||
mime_type = "application/pdf"
|
mime_type = "application/pdf"
|
||||||
|
|
||||||
|
|||||||
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"])
|
||||||
296
apps/reports/tests/test_exporters.py
Normal file
296
apps/reports/tests/test_exporters.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
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 (
|
||||||
|
_pdf_summary_rate_label,
|
||||||
|
_rate_label,
|
||||||
|
_sort_breakdown_rows,
|
||||||
|
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": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_user_summary(*, name: str, mobile: str):
|
||||||
|
return {
|
||||||
|
"user": {"id": mobile, "name": name, "mobile": mobile},
|
||||||
|
"hourly_rates": [{"amount": "15.00", "currency": "USD"}],
|
||||||
|
"rate_periods": [
|
||||||
|
{
|
||||||
|
"amount": "15.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-01",
|
||||||
|
"to_date": "2026-04-30",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_seconds": 7200,
|
||||||
|
"total_duration": "02:00:00",
|
||||||
|
"billable_seconds": 7200,
|
||||||
|
"billable_duration": "02:00:00",
|
||||||
|
"non_billable_seconds": 0,
|
||||||
|
"non_billable_duration": "00:00:00",
|
||||||
|
"income_totals": [{"amount": "30.00", "currency": "USD"}],
|
||||||
|
"project_percentages": [{"id": "1", "name": "Website", "percentage": "100"}],
|
||||||
|
"client_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}],
|
||||||
|
"tag_percentages": [{"id": "1", "name": "Design", "percentage": "100"}],
|
||||||
|
"project_income_percentages": [{"id": "1", "name": "Website", "percentage": "100"}],
|
||||||
|
"client_income_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}],
|
||||||
|
"tag_income_percentages": [{"id": "1", "name": "Design", "percentage": "100"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_variable_user_summary(*, name: str, mobile: str):
|
||||||
|
summary = make_user_summary(name=name, mobile=mobile)
|
||||||
|
summary["hourly_rates"] = [
|
||||||
|
{"amount": "15.00", "currency": "USD"},
|
||||||
|
{"amount": "18.00", "currency": "USD"},
|
||||||
|
]
|
||||||
|
summary["rate_periods"] = [
|
||||||
|
{
|
||||||
|
"amount": "15.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-01",
|
||||||
|
"to_date": "2026-04-14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": "18.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-15",
|
||||||
|
"to_date": "2026-04-30",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
class ReportExporterTests(TestCase):
|
||||||
|
def test_export_rate_labels_trim_rial_and_toman_decimals(self):
|
||||||
|
locale = build_export_locale("en")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
_rate_label(locale, {"amount": "1250.75", "currency": "USD"}),
|
||||||
|
"1,250.75 USD",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_rate_label(locale, {"amount": "1250.75", "currency": "IRR"}),
|
||||||
|
"1,250 IRR",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_rate_label(locale, {"amount": "9800.50", "currency": "IRT"}),
|
||||||
|
"9,800 IRT",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pdf_summary_uses_multiple_rates_label(self):
|
||||||
|
locale = build_export_locale("en")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
_pdf_summary_rate_label(
|
||||||
|
locale,
|
||||||
|
[
|
||||||
|
{"amount": "15.00", "currency": "USD"},
|
||||||
|
{"amount": "18.00", "currency": "USD"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"Variable rate",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_breakdown_rows_are_sorted_by_hour_percentage(self):
|
||||||
|
rows = [
|
||||||
|
{"id": "low", "name": "Low", "billable_seconds": 7200},
|
||||||
|
{"id": "high", "name": "High", "billable_seconds": 3600},
|
||||||
|
{"id": "tie", "name": "Tie", "billable_seconds": 10800},
|
||||||
|
]
|
||||||
|
percentages = [
|
||||||
|
{"id": "low", "name": "Low", "percentage": "20"},
|
||||||
|
{"id": "high", "name": "High", "percentage": "70"},
|
||||||
|
{"id": "tie", "name": "Tie", "percentage": "20"},
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[row["name"] for row in _sort_breakdown_rows(rows, percentages)],
|
||||||
|
["High", "Tie", "Low"],
|
||||||
|
)
|
||||||
|
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
report_data["user_summaries"] = [
|
||||||
|
make_variable_user_summary(name="Owner User", mobile="09129990001"),
|
||||||
|
make_user_summary(name="Team Mate", mobile="09129990002"),
|
||||||
|
]
|
||||||
|
per_user_reports = [
|
||||||
|
{
|
||||||
|
**make_report_data(
|
||||||
|
user_name="Owner User",
|
||||||
|
mobile="09129990001",
|
||||||
|
hourly_rate={"amount": "15.00", "currency": "USD"},
|
||||||
|
),
|
||||||
|
"user_summary": make_variable_user_summary(name="Owner User", mobile="09129990001"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
**make_report_data(
|
||||||
|
user_name="Team Mate",
|
||||||
|
mobile="09129990002",
|
||||||
|
hourly_rate={"amount": "15.00", "currency": "USD"},
|
||||||
|
),
|
||||||
|
"user_summary": make_user_summary(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])
|
||||||
|
|
||||||
|
summary_sheet = workbook[workbook.sheetnames[0]]
|
||||||
|
summary_values = list(summary_sheet.iter_rows(values_only=True))
|
||||||
|
|
||||||
|
self.assertEqual(summary_sheet.freeze_panes, "B1")
|
||||||
|
self.assertEqual(summary_sheet["A1"].value, "Workspace Report")
|
||||||
|
self.assertEqual(summary_sheet["B1"].value, "Exports")
|
||||||
|
self.assertEqual(summary_sheet["A15"].value, "Users Summary")
|
||||||
|
merged_ranges = {str(item) for item in summary_sheet.merged_cells.ranges}
|
||||||
|
self.assertIn("A15:F15", merged_ranges)
|
||||||
|
self.assertIn("H15:J15", merged_ranges)
|
||||||
|
self.assertIn("L15:N15", merged_ranges)
|
||||||
|
self.assertIn("P15:R15", merged_ranges)
|
||||||
|
self.assertNotIn("A15:R15", merged_ranges)
|
||||||
|
self.assertIsNone(summary_sheet["G15"].fill.fill_type)
|
||||||
|
self.assertIsNone(summary_sheet["G16"].fill.fill_type)
|
||||||
|
self.assertIsNone(summary_sheet["K15"].fill.fill_type)
|
||||||
|
self.assertIsNone(summary_sheet["O15"].fill.fill_type)
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:18],
|
||||||
|
(
|
||||||
|
"Name",
|
||||||
|
"Mobile",
|
||||||
|
"Working hours",
|
||||||
|
"Hourly rate",
|
||||||
|
"Period",
|
||||||
|
"Income",
|
||||||
|
None,
|
||||||
|
"Clients",
|
||||||
|
"Hour %",
|
||||||
|
"Income %",
|
||||||
|
None,
|
||||||
|
"Projects",
|
||||||
|
"Hour %",
|
||||||
|
"Income %",
|
||||||
|
None,
|
||||||
|
"Tags",
|
||||||
|
"Hour %",
|
||||||
|
"Income %",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertTrue(any(row and "Owner User" in row for row in summary_values))
|
||||||
|
self.assertTrue(any(row and "09129990001" in row for row in summary_values))
|
||||||
|
self.assertTrue(any(row and "Variable rate" in row for row in summary_values))
|
||||||
|
self.assertEqual(summary_sheet["A17"].border.top.style, "medium")
|
||||||
|
self.assertEqual(summary_sheet["A18"].border.top.style, "medium")
|
||||||
|
self.assertIsNone(summary_sheet["G17"].border.top)
|
||||||
|
|
||||||
|
user_sheet = workbook[workbook.sheetnames[1]]
|
||||||
|
user_values = list(user_sheet.iter_rows(values_only=True))
|
||||||
|
|
||||||
|
self.assertEqual(user_sheet.freeze_panes, "B1")
|
||||||
|
daily_header = next(row[:6] for row in user_values if row and "Date" in row)
|
||||||
|
self.assertEqual(
|
||||||
|
daily_header,
|
||||||
|
(
|
||||||
|
"Date",
|
||||||
|
"Billable hours",
|
||||||
|
"Non-billable hours",
|
||||||
|
"Total hours",
|
||||||
|
"Hourly rate",
|
||||||
|
"Income",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
daily_row = next(row[:6] for row in user_values if row and "2026/04/12" in row)
|
||||||
|
self.assertEqual(daily_row[4], "15 USD")
|
||||||
|
|
||||||
|
breakdown_header = next(row[:7] for row in user_values if row and row[0] == "Name" and row[2] == "Hour %")
|
||||||
|
self.assertEqual(
|
||||||
|
breakdown_header[:5],
|
||||||
|
(
|
||||||
|
"Name",
|
||||||
|
"Billable hours",
|
||||||
|
"Hour %",
|
||||||
|
"Income",
|
||||||
|
"Income %",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
report_data["user_summaries"] = [make_user_summary(name="Owner User", mobile="09129990001")]
|
||||||
|
per_user_reports = [
|
||||||
|
{
|
||||||
|
**make_report_data(
|
||||||
|
user_name="Owner User",
|
||||||
|
mobile="09129990001",
|
||||||
|
),
|
||||||
|
"user_summary": make_user_summary(
|
||||||
|
name="Owner User",
|
||||||
|
mobile="09129990001",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
|
||||||
|
|
||||||
|
self.assertEqual(content[:4], b"%PDF")
|
||||||
@@ -1,126 +1,41 @@
|
|||||||
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(
|
||||||
def zadd(self, *args, **kwargs):
|
mobile="09129990001",
|
||||||
return self
|
password="secret123",
|
||||||
|
first_name="Owner",
|
||||||
def hset(self, *args, **kwargs):
|
last_name="User",
|
||||||
return self
|
|
||||||
|
|
||||||
def sadd(self, *args, **kwargs):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def execute(self):
|
|
||||||
return []
|
|
||||||
|
|
||||||
def publish(self, *args, **kwargs):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def zrevrange(self, *args, **kwargs):
|
|
||||||
return []
|
|
||||||
|
|
||||||
def hget(self, *args, **kwargs):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def zrem(self, *args, **kwargs):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def hdel(self, *args, **kwargs):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def zcard(self, *args, **kwargs):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def smembers(self, *args, **kwargs):
|
|
||||||
return set()
|
|
||||||
|
|
||||||
def srem(self, *args, **kwargs):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def fake_redis(monkeypatch):
|
|
||||||
redis = FakeRedis()
|
|
||||||
monkeypatch.setattr(notification_store, "redis_client", redis)
|
|
||||||
return redis
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def owner(db):
|
|
||||||
return User.objects.create_user(mobile="09129990001", password="secret123", first_name="Owner", last_name="User")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def teammate(db):
|
|
||||||
return User.objects.create_user(mobile="09129990002", password="secret123", first_name="Team", last_name="Mate")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def workspace(owner, teammate):
|
|
||||||
workspace = Workspace.objects.create(name="Exports", owner=owner)
|
|
||||||
workspace.memberships.create(user=teammate, role="member", is_active=True)
|
|
||||||
return workspace
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def time_entry(workspace, owner):
|
|
||||||
return TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=owner,
|
|
||||||
description="Export row",
|
|
||||||
start_time="2026-04-12T08:00:00+03:30",
|
|
||||||
end_time="2026-04-12T10:00:00+03:30",
|
|
||||||
duration=timedelta(hours=2),
|
|
||||||
is_billable=True,
|
|
||||||
hourly_rate=Decimal("15.00"),
|
|
||||||
currency="USD",
|
|
||||||
)
|
)
|
||||||
|
cls.workspace = Workspace.objects.create(name="Exports", owner=cls.owner)
|
||||||
|
|
||||||
|
def test_generate_excel_export_marks_job_complete_and_sends_notification(self):
|
||||||
@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(
|
job = ReportExportJob.objects.create(
|
||||||
requesting_user=owner,
|
requesting_user=self.owner,
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
export_type=ReportExportJob.ExportType.EXCEL,
|
export_type=ReportExportJob.ExportType.EXCEL,
|
||||||
filters={
|
filters={
|
||||||
"workspace": str(workspace.id),
|
"workspace": str(self.workspace.id),
|
||||||
"period": "this_month",
|
"period": "this_month",
|
||||||
"from_date": "2026-04-01",
|
"from_date": "2026-04-01",
|
||||||
"to_date": "2026-04-30",
|
"to_date": "2026-04-30",
|
||||||
"user": str(owner.id),
|
"user": str(self.owner.id),
|
||||||
"client": None,
|
"client": None,
|
||||||
"project": None,
|
"project": None,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
@@ -128,60 +43,34 @@ def test_generate_export_creates_file_and_marks_job_complete(fake_redis, workspa
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with patch("apps.reports.tasks.build_table_report", return_value={"scope": {}, "summary": {}, "days": [], "clients": [], "projects": [], "tags": []}) as build_table_report:
|
||||||
|
with patch("apps.reports.tasks.build_user_scoped_table_reports", return_value=[]) as build_user_reports:
|
||||||
|
with patch("apps.reports.tasks.build_excel_report", return_value=b"excel-content") as build_excel_report:
|
||||||
|
with patch("apps.reports.tasks.RedisNotificationStore.add") as notify:
|
||||||
generate_report_export_task(str(job.id))
|
generate_report_export_task(str(job.id))
|
||||||
|
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
|
self.assertEqual(job.status, ReportExportJob.Status.COMPLETED)
|
||||||
|
self.assertTrue(bool(job.file))
|
||||||
|
self.assertTrue(default_storage.exists(job.file.name))
|
||||||
|
build_table_report.assert_called_once()
|
||||||
|
build_user_reports.assert_called_once()
|
||||||
|
build_excel_report.assert_called_once()
|
||||||
|
notify.assert_called_once()
|
||||||
|
self.assertEqual(notify.call_args.args[0], str(self.owner.id))
|
||||||
|
self.assertEqual(notify.call_args.args[1]["type"], "report_export_ready")
|
||||||
|
|
||||||
assert job.status == ReportExportJob.Status.COMPLETED
|
def test_generate_pdf_export_failure_marks_job_failed_and_notifies(self):
|
||||||
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(
|
job = ReportExportJob.objects.create(
|
||||||
requesting_user=owner,
|
requesting_user=self.owner,
|
||||||
workspace=workspace,
|
workspace=self.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_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,
|
export_type=ReportExportJob.ExportType.PDF,
|
||||||
filters={
|
filters={
|
||||||
"workspace": str(workspace.id),
|
"workspace": str(self.workspace.id),
|
||||||
"period": "this_month",
|
"period": "this_month",
|
||||||
"from_date": "2026-04-01",
|
"from_date": "2026-04-01",
|
||||||
"to_date": "2026-04-30",
|
"to_date": "2026-04-30",
|
||||||
"user": str(owner.id),
|
"user": str(self.owner.id),
|
||||||
"client": None,
|
"client": None,
|
||||||
"project": None,
|
"project": None,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
@@ -189,17 +78,22 @@ def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owne
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with patch("apps.reports.tasks.build_table_report", return_value={"scope": {}, "summary": {}, "days": [], "clients": [], "projects": [], "tags": []}):
|
||||||
|
with patch("apps.reports.tasks.build_pdf_report", side_effect=RuntimeError("boom")):
|
||||||
|
with patch("apps.reports.tasks.RedisNotificationStore.add") as notify:
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
generate_report_export_task(str(job.id))
|
generate_report_export_task(str(job.id))
|
||||||
|
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
|
self.assertEqual(job.status, ReportExportJob.Status.FAILED)
|
||||||
|
self.assertEqual(job.error_message, "boom")
|
||||||
|
notify.assert_called_once()
|
||||||
|
self.assertEqual(notify.call_args.args[1]["type"], "report_export_failed")
|
||||||
|
|
||||||
assert job.status == ReportExportJob.Status.COMPLETED
|
def test_cleanup_expires_and_removes_files(self):
|
||||||
assert job.file.read(4) == b"%PDF"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_expires_and_removes_files(fake_redis, workspace, owner):
|
|
||||||
job = ReportExportJob.objects.create(
|
job = ReportExportJob.objects.create(
|
||||||
requesting_user=owner,
|
requesting_user=self.owner,
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
export_type=ReportExportJob.ExportType.EXCEL,
|
export_type=ReportExportJob.ExportType.EXCEL,
|
||||||
status=ReportExportJob.Status.COMPLETED,
|
status=ReportExportJob.Status.COMPLETED,
|
||||||
filters={},
|
filters={},
|
||||||
@@ -212,6 +106,6 @@ def test_cleanup_expires_and_removes_files(fake_redis, workspace, owner):
|
|||||||
removed = cleanup_expired_report_exports_task()
|
removed = cleanup_expired_report_exports_task()
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
|
|
||||||
assert removed == 1
|
self.assertEqual(removed, 1)
|
||||||
assert job.status == ReportExportJob.Status.EXPIRED
|
self.assertEqual(job.status, ReportExportJob.Status.EXPIRED)
|
||||||
assert not default_storage.exists(file_name)
|
self.assertFalse(default_storage.exists(file_name))
|
||||||
|
|||||||
@@ -1,66 +1,65 @@
|
|||||||
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
|
||||||
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
|
||||||
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, WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
@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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def owner(db):
|
|
||||||
return User.objects.create_user(mobile="09128880001", password="secret123", first_name="Owner")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def admin(db):
|
|
||||||
return User.objects.create_user(mobile="09128880002", password="secret123", first_name="Admin")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def member(db):
|
|
||||||
return User.objects.create_user(mobile="09128880003", password="secret123", first_name="Member")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def workspace(owner, admin, member):
|
|
||||||
workspace = Workspace.objects.create(name="Reports", owner=owner)
|
|
||||||
WorkspaceMembership.objects.create(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True)
|
|
||||||
WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True)
|
|
||||||
return workspace
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def client(workspace):
|
|
||||||
return Client.objects.create(workspace=workspace, name="Acme")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def project(workspace, client):
|
|
||||||
return Project.objects.create(workspace=workspace, name="Website", client=client)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tag(workspace):
|
|
||||||
return Tag.objects.create(workspace=workspace, name="Design", color="#ffffff")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def time_entries(workspace, owner, member, project, tag):
|
|
||||||
entry_owner = TimeEntry.objects.create(
|
entry_owner = TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=cls.workspace,
|
||||||
user=owner,
|
user=cls.owner,
|
||||||
project=project,
|
project=cls.project,
|
||||||
description="Owner work",
|
description="Owner work",
|
||||||
start_time="2026-04-10T08:00:00+03:30",
|
start_time="2026-04-10T08:00:00+03:30",
|
||||||
end_time="2026-04-10T10:00:00+03:30",
|
end_time="2026-04-10T10:00:00+03:30",
|
||||||
@@ -69,11 +68,12 @@ def time_entries(workspace, owner, member, project, tag):
|
|||||||
hourly_rate=Decimal("25.00"),
|
hourly_rate=Decimal("25.00"),
|
||||||
currency="USD",
|
currency="USD",
|
||||||
)
|
)
|
||||||
entry_owner.tags.add(tag)
|
entry_owner.tags.add(cls.tag)
|
||||||
|
|
||||||
entry_member = TimeEntry.objects.create(
|
entry_member = TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=cls.workspace,
|
||||||
user=member,
|
user=cls.member,
|
||||||
project=project,
|
project=cls.project,
|
||||||
description="Member work",
|
description="Member work",
|
||||||
start_time="2026-04-11T09:00:00+03:30",
|
start_time="2026-04-11T09:00:00+03:30",
|
||||||
end_time="2026-04-11T10:00:00+03:30",
|
end_time="2026-04-11T10:00:00+03:30",
|
||||||
@@ -81,59 +81,340 @@ def time_entries(workspace, owner, member, project, tag):
|
|||||||
is_billable=False,
|
is_billable=False,
|
||||||
currency="USD",
|
currency="USD",
|
||||||
)
|
)
|
||||||
entry_member.tags.add(tag)
|
entry_member.tags.add(cls.tag)
|
||||||
return [entry_owner, entry_member]
|
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
def test_member_only_sees_own_chart_report(api_client, member, workspace, time_entries):
|
def test_member_only_sees_own_chart_report(self):
|
||||||
api_client.force_authenticate(user=member)
|
self.client.force_authenticate(user=self.member)
|
||||||
|
|
||||||
response = api_client.get(
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
"/api/reports/chart/",
|
"/api/reports/chart/",
|
||||||
{"workspace": str(workspace.id), "period": "this_month"},
|
{"workspace": str(self.workspace.id), "period": "this_month"},
|
||||||
)
|
)
|
||||||
|
|
||||||
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"], "01:00:00")
|
||||||
|
self.assertEqual(len(response.data["series"]), 1)
|
||||||
|
self.assertEqual(response.data["series"][0]["user"]["id"], str(self.member.id))
|
||||||
|
|
||||||
|
def test_admin_chart_without_user_filter_returns_series_for_all_users(self):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
|
||||||
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),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/reports/chart/",
|
||||||
|
{"workspace": str(self.workspace.id), "period": "this_month"},
|
||||||
|
)
|
||||||
|
|
||||||
response = api_client.get(
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
|
||||||
|
self.assertEqual(len(response.data["series"]), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
{series["user"]["id"] for series in response.data["series"]},
|
||||||
|
{str(self.owner.id), str(self.member.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_admin_can_request_combined_table_report(self):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
"/api/reports/table/",
|
"/api/reports/table/",
|
||||||
{"workspace": str(workspace.id), "period": "this_month"},
|
{"workspace": str(self.workspace.id), "period": "this_month"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response.data["summary"]["total_duration"] == "03:00:00"
|
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
|
||||||
assert len(response.data["days"]) == 2
|
self.assertEqual(len(response.data["days"]), 2)
|
||||||
|
self.assertEqual(len(response.data["user_summaries"]), 2)
|
||||||
|
self.assertIsNone(response.data["days"][0]["latest_hourly_rate"])
|
||||||
|
self.assertIsNone(response.data["days"][1]["latest_hourly_rate"])
|
||||||
|
summaries = {item["user"]["id"]: item for item in response.data["user_summaries"]}
|
||||||
|
owner_summary = summaries[str(self.owner.id)]
|
||||||
|
member_summary = summaries[str(self.member.id)]
|
||||||
|
self.assertEqual(owner_summary["project_percentages"][0]["percentage"], "100")
|
||||||
|
self.assertEqual(owner_summary["client_percentages"][0]["percentage"], "100")
|
||||||
|
self.assertEqual(owner_summary["tag_percentages"][0]["percentage"], "100")
|
||||||
|
self.assertEqual(member_summary["project_percentages"][0]["percentage"], "0")
|
||||||
|
self.assertEqual(member_summary["client_percentages"][0]["percentage"], "0")
|
||||||
|
self.assertEqual(member_summary["tag_percentages"][0]["percentage"], "0")
|
||||||
|
|
||||||
|
def test_specific_user_report_includes_uncategorized_rows_and_balanced_percentages(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace):
|
TimeEntry.objects.create(
|
||||||
api_client.force_authenticate(user=owner)
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
project=None,
|
||||||
|
description="Uncategorized billable",
|
||||||
|
start_time="2026-04-12T10:00:00+03:30",
|
||||||
|
end_time="2026-04-12T11:00:00+03:30",
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("10.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
|
||||||
response = api_client.get(
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/reports/table/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"user": str(self.owner.id),
|
||||||
|
"language": "en",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
summary = response.data["user_summary"]
|
||||||
|
self.assertEqual(
|
||||||
|
sum(int(row["percentage"]) for row in summary["project_percentages"]),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(int(row["percentage"]) for row in summary["client_percentages"]),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(int(row["percentage"]) for row in summary["tag_percentages"]),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{row["name"] for row in summary["project_percentages"]},
|
||||||
|
{"Website", "No project"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{row["name"] for row in summary["client_percentages"]},
|
||||||
|
{"Acme", "No client"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{row["name"] for row in summary["tag_percentages"]},
|
||||||
|
{"Design", "No tag"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{row["name"] for row in response.data["projects"]},
|
||||||
|
{"Website", "No project"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{row["name"] for row in response.data["clients"]},
|
||||||
|
{"Acme", "No client"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{row["name"] for row in response.data["tags"]},
|
||||||
|
{"Design", "No tag"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(int(row["percentage"]) for row in summary["project_income_percentages"]),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(int(row["percentage"]) for row in summary["client_income_percentages"]),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(int(row["percentage"]) for row in summary["tag_income_percentages"]),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_income_percentages_are_hidden_for_mixed_currency_breakdowns(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
second_project = Project.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
name="Mobile App",
|
||||||
|
client=self.client_obj,
|
||||||
|
)
|
||||||
|
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
project=second_project,
|
||||||
|
description="EUR work",
|
||||||
|
start_time="2026-04-13T10:00:00+03:30",
|
||||||
|
end_time="2026-04-13T11:00:00+03:30",
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("20.00"),
|
||||||
|
currency="EUR",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
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),
|
||||||
|
"language": "en",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
summary = response.data["user_summary"]
|
||||||
|
self.assertEqual(summary["project_income_percentages"], [])
|
||||||
|
self.assertEqual(summary["client_income_percentages"], [])
|
||||||
|
|
||||||
|
def test_daily_rate_uses_latest_billable_entry_snapshot(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_user_summary_endpoint_keeps_workspace_rate_history_and_marks_current_row_open(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
project=None,
|
||||||
|
description="Legacy workspace rate",
|
||||||
|
start_time="2026-04-08T08:00:00+03:30",
|
||||||
|
end_time="2026-04-08T09:00:00+03:30",
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("12.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
project=self.project,
|
||||||
|
description="Current project rate",
|
||||||
|
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("25.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
WorkspaceUserRate.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
hourly_rate=Decimal("12.00"),
|
||||||
|
currency="USD",
|
||||||
|
effective_from="2026-04-01T00:00:00+03:30",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/reports/user-summary/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"user": str(self.owner.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
rate_periods = response.data["user_summary"]["rate_periods"]
|
||||||
|
self.assertEqual(
|
||||||
|
rate_periods,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"amount": "12.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-08",
|
||||||
|
"to_date": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": "25.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-10",
|
||||||
|
"to_date": "2026-04-12",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_custom_period_longer_than_31_days_is_rejected(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
"/api/reports/chart/",
|
"/api/reports/chart/",
|
||||||
{
|
{
|
||||||
"workspace": str(workspace.id),
|
"workspace": str(self.workspace.id),
|
||||||
"period": "period",
|
"period": "period",
|
||||||
"from_date": "2026-01-01",
|
"from_date": "2026-01-01",
|
||||||
"to_date": "2026-02-15",
|
"to_date": "2026-02-15",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_persian_this_month_uses_jalali_month_bounds(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
def test_persian_this_month_uses_jalali_month_bounds(api_client, owner, workspace, project, monkeypatch):
|
with patch(
|
||||||
api_client.force_authenticate(user=owner)
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
monkeypatch.setattr("apps.reports.services.aggregation.timezone.localdate", lambda: date(2026, 4, 27))
|
return_value=date(2026, 4, 27),
|
||||||
|
):
|
||||||
TimeEntry.objects.create(
|
TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
user=owner,
|
user=self.owner,
|
||||||
project=project,
|
project=self.project,
|
||||||
description="Previous jalali month",
|
description="Previous jalali month",
|
||||||
start_time="2026-04-20T08:00:00+03:30",
|
start_time="2026-04-20T08:00:00+03:30",
|
||||||
end_time="2026-04-20T09:00:00+03:30",
|
end_time="2026-04-20T09:00:00+03:30",
|
||||||
@@ -142,9 +423,9 @@ def test_persian_this_month_uses_jalali_month_bounds(api_client, owner, workspac
|
|||||||
currency="USD",
|
currency="USD",
|
||||||
)
|
)
|
||||||
TimeEntry.objects.create(
|
TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
user=owner,
|
user=self.owner,
|
||||||
project=project,
|
project=self.project,
|
||||||
description="Current jalali month",
|
description="Current jalali month",
|
||||||
start_time="2026-04-21T08:00:00+03:30",
|
start_time="2026-04-21T08:00:00+03:30",
|
||||||
end_time="2026-04-21T10:00:00+03:30",
|
end_time="2026-04-21T10:00:00+03:30",
|
||||||
@@ -153,11 +434,51 @@ def test_persian_this_month_uses_jalali_month_bounds(api_client, owner, workspac
|
|||||||
currency="USD",
|
currency="USD",
|
||||||
)
|
)
|
||||||
|
|
||||||
response = api_client.get(
|
response = self.client.get(
|
||||||
"/api/reports/table/",
|
"/api/reports/table/",
|
||||||
{"workspace": str(workspace.id), "period": "this_month", "language": "fa"},
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"language": "fa",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response.data["summary"]["total_duration"] == "02:00:00"
|
self.assertEqual(response.data["summary"]["total_duration"], "02:00:00")
|
||||||
assert response.data["scope"]["from_date"] == "2026-04-21"
|
self.assertEqual(response.data["scope"]["from_date"], "2026-04-21")
|
||||||
|
|
||||||
|
def test_table_report_cache_stays_until_time_entry_invalidation(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
url = "/api/reports/table/"
|
||||||
|
params = {"workspace": str(self.workspace.id), "period": "this_month"}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
first_response = self.client.get(url, params)
|
||||||
|
self.assertEqual(first_response.status_code, 200)
|
||||||
|
self.assertEqual(first_response.data["summary"]["total_duration"], "03:00:00")
|
||||||
|
|
||||||
|
member_entry = TimeEntry.objects.get(description="Member work")
|
||||||
|
TimeEntry.objects.filter(id=member_entry.id).update(duration=timedelta(hours=5))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
cached_response = self.client.get(url, params)
|
||||||
|
self.assertEqual(cached_response.status_code, 200)
|
||||||
|
self.assertEqual(cached_response.data["summary"]["total_duration"], "03:00:00")
|
||||||
|
|
||||||
|
member_entry.refresh_from_db()
|
||||||
|
member_entry.description = "Member work updated"
|
||||||
|
member_entry.save(update_fields=["description"])
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
fresh_response = self.client.get(url, params)
|
||||||
|
self.assertEqual(fresh_response.status_code, 200)
|
||||||
|
self.assertEqual(fresh_response.data["summary"]["total_duration"], "07:00:00")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from core.admins.base import BaseAdmin
|
from core.admins.base import BaseAdmin, SoftDeleteListFilter
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ class TagAdmin(BaseAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
list_filter = (
|
list_filter = (
|
||||||
|
SoftDeleteListFilter,
|
||||||
"workspace",
|
"workspace",
|
||||||
"is_deleted",
|
"is_deleted",
|
||||||
)
|
)
|
||||||
|
|||||||
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)
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from core.serializers.base import BaseModelSerializer
|
from core.serializers.base import BaseModelSerializer
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
|
from apps.projects.services.access import ensure_project_access
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +32,27 @@ class TimeEntrySerializer(BaseModelSerializer):
|
|||||||
tag_details = serializers.SerializerMethodField()
|
tag_details = serializers.SerializerMethodField()
|
||||||
start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S")
|
start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S")
|
||||||
end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True)
|
end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True)
|
||||||
|
start_time_ms = serializers.SerializerMethodField()
|
||||||
|
end_time_ms = serializers.SerializerMethodField()
|
||||||
|
server_now_ms = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _epoch_ms(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if timezone.is_naive(value):
|
||||||
|
value = timezone.make_aware(value, timezone.get_current_timezone())
|
||||||
|
return int(value.timestamp() * 1000)
|
||||||
|
|
||||||
|
def get_start_time_ms(self, obj):
|
||||||
|
return self._epoch_ms(obj.start_time)
|
||||||
|
|
||||||
|
def get_end_time_ms(self, obj):
|
||||||
|
return self._epoch_ms(obj.end_time)
|
||||||
|
|
||||||
|
def get_server_now_ms(self, obj):
|
||||||
|
server_now = self.context.get("server_now") or timezone.now()
|
||||||
|
return self._epoch_ms(server_now)
|
||||||
|
|
||||||
def get_tags(self, obj):
|
def get_tags(self, obj):
|
||||||
return [str(tag.id) for tag in Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")]
|
return [str(tag.id) for tag in Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")]
|
||||||
@@ -75,7 +98,10 @@ class TimeEntrySerializer(BaseModelSerializer):
|
|||||||
"project_details",
|
"project_details",
|
||||||
"description",
|
"description",
|
||||||
"start_time",
|
"start_time",
|
||||||
|
"start_time_ms",
|
||||||
"end_time",
|
"end_time",
|
||||||
|
"end_time_ms",
|
||||||
|
"server_now_ms",
|
||||||
"duration",
|
"duration",
|
||||||
"tags",
|
"tags",
|
||||||
"tag_details",
|
"tag_details",
|
||||||
@@ -92,13 +118,21 @@ class TimeEntryCreateSerializer(serializers.Serializer):
|
|||||||
"""
|
"""
|
||||||
workspace_id = serializers.UUIDField()
|
workspace_id = serializers.UUIDField()
|
||||||
project_id = serializers.UUIDField(required=False, allow_null=True)
|
project_id = serializers.UUIDField(required=False, allow_null=True)
|
||||||
start_time = serializers.DateTimeField()
|
start_time = serializers.DateTimeField(required=False)
|
||||||
end_time = serializers.DateTimeField(required=False, allow_null=True)
|
end_time = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
description = serializers.CharField(required=False, allow_blank=True, default="")
|
description = serializers.CharField(required=False, allow_blank=True, default="")
|
||||||
tags = serializers.ListField(child=serializers.UUIDField(), required=False)
|
tags = serializers.ListField(child=serializers.UUIDField(), required=False)
|
||||||
is_billable = serializers.BooleanField(default=False)
|
is_billable = serializers.BooleanField(default=False)
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
|
user = self.context.get("request").user if self.context.get("request") else None
|
||||||
|
workspace_id = attrs.get("workspace_id")
|
||||||
|
start_time = attrs.get("start_time")
|
||||||
|
end_time = attrs.get("end_time")
|
||||||
|
|
||||||
|
if end_time is not None and start_time is None:
|
||||||
|
raise serializers.ValidationError({"start_time": "Start time is required when end time is provided."})
|
||||||
|
|
||||||
project_id = attrs.pop("project_id", serializers.empty)
|
project_id = attrs.pop("project_id", serializers.empty)
|
||||||
if project_id is not serializers.empty:
|
if project_id is not serializers.empty:
|
||||||
if project_id is None:
|
if project_id is None:
|
||||||
@@ -107,6 +141,10 @@ class TimeEntryCreateSerializer(serializers.Serializer):
|
|||||||
project = Project.objects.filter(id=project_id).first()
|
project = Project.objects.filter(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
if workspace_id and str(project.workspace_id) != str(workspace_id):
|
||||||
|
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
if user:
|
||||||
|
ensure_project_access(user, project)
|
||||||
attrs["project"] = project
|
attrs["project"] = project
|
||||||
|
|
||||||
tag_ids = attrs.pop("tags", serializers.empty)
|
tag_ids = attrs.pop("tags", serializers.empty)
|
||||||
@@ -134,6 +172,7 @@ class TimeEntryUpdateSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
entry = self.instance
|
entry = self.instance
|
||||||
|
user = self.context.get("request").user if self.context.get("request") else None
|
||||||
|
|
||||||
project_id = attrs.pop("project_id", serializers.empty)
|
project_id = attrs.pop("project_id", serializers.empty)
|
||||||
if project_id is not serializers.empty:
|
if project_id is not serializers.empty:
|
||||||
@@ -146,6 +185,10 @@ class TimeEntryUpdateSerializer(serializers.Serializer):
|
|||||||
project = Project.objects.filter(id=project_id).first()
|
project = Project.objects.filter(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
if entry and str(project.workspace_id) != str(entry.workspace_id):
|
||||||
|
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
if user:
|
||||||
|
ensure_project_access(user, project)
|
||||||
attrs["project"] = project
|
attrs["project"] = project
|
||||||
|
|
||||||
tag_ids = attrs.pop("tags", serializers.empty)
|
tag_ids = attrs.pop("tags", serializers.empty)
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
filterset_class = TimeEntryFilter
|
filterset_class = TimeEntryFilter
|
||||||
search_fields = ["description", "project__name", "project__client__name", "tags__name"]
|
search_fields = ["description", "project__name", "project__client__name", "tags__name"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _epoch_ms(value):
|
||||||
|
if timezone.is_naive(value):
|
||||||
|
value = timezone.make_aware(value, timezone.get_current_timezone())
|
||||||
|
return int(value.timestamp() * 1000)
|
||||||
|
|
||||||
|
def _serializer_context(self, *, server_now=None):
|
||||||
|
context = self.get_serializer_context()
|
||||||
|
context["server_now"] = server_now or timezone.now()
|
||||||
|
return context
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_duration_ms(entry):
|
def _serialize_duration_ms(entry):
|
||||||
if entry.duration is not None:
|
if entry.duration is not None:
|
||||||
@@ -51,8 +62,12 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
days_since_sunday = (local_dt.weekday() + 1) % 7
|
days_since_sunday = (local_dt.weekday() + 1) % 7
|
||||||
return (local_dt - timedelta(days=days_since_sunday)).date()
|
return (local_dt - timedelta(days=days_since_sunday)).date()
|
||||||
|
|
||||||
def _build_grouped_entries(self, entries):
|
def _build_grouped_entries(self, entries, *, server_now):
|
||||||
serialized_entries = TimeEntrySerializer(entries, many=True, context=self.get_serializer_context()).data
|
serialized_entries = TimeEntrySerializer(
|
||||||
|
entries,
|
||||||
|
many=True,
|
||||||
|
context=self._serializer_context(server_now=server_now),
|
||||||
|
).data
|
||||||
serialized_by_id = {item["id"]: item for item in serialized_entries}
|
serialized_by_id = {item["id"]: item for item in serialized_entries}
|
||||||
weeks = []
|
weeks = []
|
||||||
weeks_by_key = {}
|
weeks_by_key = {}
|
||||||
@@ -114,6 +129,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
queryset = self.filter_queryset(self.get_queryset())
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
paginator = self.pagination_class()
|
paginator = self.pagination_class()
|
||||||
page = paginator.paginate_queryset(queryset, request, view=self)
|
page = paginator.paginate_queryset(queryset, request, view=self)
|
||||||
|
server_now = timezone.now()
|
||||||
current_items_count = len(page)
|
current_items_count = len(page)
|
||||||
has_more = (paginator.offset + current_items_count) < paginator.count
|
has_more = (paginator.offset + current_items_count) < paginator.count
|
||||||
|
|
||||||
@@ -125,7 +141,19 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
"offset": paginator.offset,
|
"offset": paginator.offset,
|
||||||
"next_offset": paginator.offset + current_items_count if has_more else None,
|
"next_offset": paginator.offset + current_items_count if has_more else None,
|
||||||
"has_more": has_more,
|
"has_more": has_more,
|
||||||
"groups": self._build_grouped_entries(page),
|
"server_now_ms": self._epoch_ms(server_now),
|
||||||
|
"server_now": server_now.isoformat(),
|
||||||
|
"groups": self._build_grouped_entries(page, server_now=server_now),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], url_path="debug-time")
|
||||||
|
def debug_time(self, request):
|
||||||
|
server_now = timezone.now()
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"server_now_ms": self._epoch_ms(server_now),
|
||||||
|
"server_now": server_now.isoformat(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -148,7 +176,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
**serializer.validated_data
|
**serializer.validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
output_serializer = TimeEntrySerializer(entry, context=self.get_serializer_context())
|
output_serializer = TimeEntrySerializer(entry, context=self._serializer_context())
|
||||||
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
@@ -168,7 +196,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
**serializer.validated_data
|
**serializer.validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
output_serializer = TimeEntrySerializer(updated_entry, context=self.get_serializer_context())
|
output_serializer = TimeEntrySerializer(updated_entry, context=self._serializer_context())
|
||||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
@action(detail=True, methods=["post"])
|
||||||
@@ -189,7 +217,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
end_time = serializer.validated_data.get("end_time")
|
end_time = serializer.validated_data.get("end_time")
|
||||||
stopped_entry = stop_time_entry(entry, end_time=end_time)
|
stopped_entry = stop_time_entry(entry, end_time=end_time)
|
||||||
|
|
||||||
output_serializer = TimeEntrySerializer(stopped_entry, context=self.get_serializer_context())
|
output_serializer = TimeEntrySerializer(stopped_entry, context=self._serializer_context())
|
||||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-04-30 12:23
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('projects', '0003_project_project_ws_arch_upd_idx'),
|
||||||
|
('tags', '0001_initial'),
|
||||||
|
('time_entries', '0001_initial'),
|
||||||
|
('workspaces', '0007_workspacemembership_membership_ws_active_user_idx'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='timeentry',
|
||||||
|
index=models.Index(fields=['workspace', 'user', 'start_time'], name='time_entry_ws_user_start_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -62,6 +62,7 @@ class TimeEntry(BaseModel):
|
|||||||
models.Index(fields=["project"], name="time_entry_project_idx"),
|
models.Index(fields=["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(
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
from apps.projects.services.access import user_has_project_access
|
||||||
|
from apps.projects.services.rates import get_current_project_user_rate
|
||||||
from apps.workspaces.models import WorkspaceUserRate
|
from apps.workspaces.models import WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
def resolve_rate(user, project):
|
def resolve_rate(user, project):
|
||||||
|
if user_has_project_access(user, project):
|
||||||
|
project_user_rate = get_current_project_user_rate(project=project, user=user)
|
||||||
|
if project_user_rate:
|
||||||
|
return project_user_rate.hourly_rate, project_user_rate.currency
|
||||||
|
|
||||||
workspace_user_rate = WorkspaceUserRate.objects.filter(
|
workspace_user_rate = WorkspaceUserRate.objects.filter(
|
||||||
user=user,
|
user=user,
|
||||||
workspace=project.workspace,
|
workspace=project.workspace,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.exceptions import ValidationError, PermissionDenied
|
from rest_framework.exceptions import ValidationError, PermissionDenied
|
||||||
|
|
||||||
|
from apps.projects.services.access import user_has_project_access
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
from apps.time_entries.services.rates import resolve_rate
|
from apps.time_entries.services.rates import resolve_rate
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
@@ -21,7 +22,7 @@ def _verify_workspace_access(user, workspace_id):
|
|||||||
raise PermissionDenied("You do not have access to this workspace.")
|
raise PermissionDenied("You do not have access to this workspace.")
|
||||||
|
|
||||||
|
|
||||||
def create_time_entry(user, workspace_id, start_time, end_time=None, project=None, tags=None, description="", is_billable=False):
|
def create_time_entry(user, workspace_id, start_time=None, end_time=None, project=None, tags=None, description="", is_billable=False):
|
||||||
"""
|
"""
|
||||||
Creates a new time entry. If end_time is None, it acts as a running timer.
|
Creates a new time entry. If end_time is None, it acts as a running timer.
|
||||||
"""
|
"""
|
||||||
@@ -37,11 +38,18 @@ def create_time_entry(user, workspace_id, start_time, end_time=None, project=Non
|
|||||||
if has_running_timer:
|
if has_running_timer:
|
||||||
raise ValidationError({"non_field_errors": "You already have a running timer in this workspace."})
|
raise ValidationError({"non_field_errors": "You already have a running timer in this workspace."})
|
||||||
|
|
||||||
|
if start_time is None:
|
||||||
|
if end_time is not None:
|
||||||
|
raise ValidationError({"start_time": "Start time is required when end time is provided."})
|
||||||
|
start_time = timezone.now()
|
||||||
|
|
||||||
if start_time and end_time and start_time >= end_time:
|
if start_time and end_time and start_time >= end_time:
|
||||||
raise ValidationError({"end_time": "End time must be strictly after start time."})
|
raise ValidationError({"end_time": "End time must be strictly after start time."})
|
||||||
|
|
||||||
if project and project.workspace_id != workspace_id:
|
if project and project.workspace_id != workspace_id:
|
||||||
raise ValidationError({"project": "Project must belong to the same workspace."})
|
raise ValidationError({"project": "Project must belong to the same workspace."})
|
||||||
|
if project and not user_has_project_access(user, project):
|
||||||
|
raise ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
|
||||||
duration = (end_time - start_time) if end_time else None
|
duration = (end_time - start_time) if end_time else None
|
||||||
|
|
||||||
@@ -79,6 +87,8 @@ def update_time_entry(entry, **kwargs):
|
|||||||
project = kwargs.get("project", entry.project)
|
project = kwargs.get("project", entry.project)
|
||||||
if project and project.workspace_id != entry.workspace_id:
|
if project and project.workspace_id != entry.workspace_id:
|
||||||
raise ValidationError({"project": "Project must belong to the same workspace."})
|
raise ValidationError({"project": "Project must belong to the same workspace."})
|
||||||
|
if project and not user_has_project_access(entry.user, project):
|
||||||
|
raise ValidationError({"project_id": "Selected project is unavailable."})
|
||||||
|
|
||||||
start_time = kwargs.get("start_time", entry.start_time)
|
start_time = kwargs.get("start_time", entry.start_time)
|
||||||
end_time = kwargs.get("end_time", entry.end_time)
|
end_time = kwargs.get("end_time", entry.end_time)
|
||||||
|
|||||||
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,17 +14,34 @@ 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),
|
||||||
|
current_timezone,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_time_entry_filter_supports_project_client_tags_and_custom_dates(db):
|
class TimeEntryFilterTests(TestCase):
|
||||||
|
def test_time_entry_filter_supports_project_client_tags_and_custom_dates(self):
|
||||||
user = User.objects.create_user(mobile="09124444444", password="secret123")
|
user = User.objects.create_user(mobile="09124444444", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
client_a = Client.objects.create(workspace=workspace, name="Client A")
|
client_a = Client.objects.create(workspace=workspace, name="Client A")
|
||||||
client_b = Client.objects.create(workspace=workspace, name="Client B")
|
client_b = Client.objects.create(workspace=workspace, name="Client B")
|
||||||
project_a = Project.objects.create(workspace=workspace, client=client_a, name="Project A")
|
project_a = Project.objects.create(
|
||||||
project_b = Project.objects.create(workspace=workspace, client=client_b, name="Project B")
|
workspace=workspace,
|
||||||
tag_backend = Tag.objects.create(workspace=workspace, name="Backend", color="#0EA5E9")
|
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")
|
tag_ops = Tag.objects.create(workspace=workspace, name="Ops", color="#10B981")
|
||||||
|
|
||||||
entry_a = TimeEntry.objects.create(
|
entry_a = TimeEntry.objects.create(
|
||||||
@@ -59,10 +78,9 @@ def test_time_entry_filter_supports_project_client_tags_and_custom_dates(db):
|
|||||||
queryset=queryset,
|
queryset=queryset,
|
||||||
).qs
|
).qs
|
||||||
|
|
||||||
assert list(filtered) == [entry_a]
|
self.assertEqual(list(filtered), [entry_a])
|
||||||
|
|
||||||
|
def test_time_entry_filter_supports_status_values(self):
|
||||||
def test_time_entry_filter_supports_status_values(db):
|
|
||||||
user = User.objects.create_user(mobile="09125555555", password="secret123")
|
user = User.objects.create_user(mobile="09125555555", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
|
|
||||||
@@ -85,5 +103,5 @@ def test_time_entry_filter_supports_status_values(db):
|
|||||||
ended = TimeEntryFilter(data={"status": "ended"}, queryset=queryset).qs
|
ended = TimeEntryFilter(data={"status": "ended"}, queryset=queryset).qs
|
||||||
running = TimeEntryFilter(data={"status": "running"}, queryset=queryset).qs
|
running = TimeEntryFilter(data={"status": "running"}, queryset=queryset).qs
|
||||||
|
|
||||||
assert list(ended) == [ended_entry]
|
self.assertEqual(list(ended), [ended_entry])
|
||||||
assert list(running) == [running_entry]
|
self.assertEqual(list(running), [running_entry])
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
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):
|
||||||
|
def test_time_entry_serializer_keeps_seconds(self):
|
||||||
user = User.objects.create_user(mobile="09123333333", password="secret123")
|
user = User.objects.create_user(mobile="09123333333", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
current_timezone = timezone.get_current_timezone()
|
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,
|
||||||
@@ -27,11 +35,10 @@ def test_time_entry_serializer_keeps_seconds(db):
|
|||||||
|
|
||||||
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):
|
||||||
def test_time_entry_serializer_includes_deleted_project_and_tags(db):
|
|
||||||
user = User.objects.create_user(mobile="09124444444", password="secret123")
|
user = User.objects.create_user(mobile="09124444444", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
project = Project.objects.create(workspace=workspace, name="Legacy Project")
|
project = Project.objects.create(workspace=workspace, name="Legacy Project")
|
||||||
@@ -51,9 +58,9 @@ def test_time_entry_serializer_includes_deleted_project_and_tags(db):
|
|||||||
|
|
||||||
data = TimeEntrySerializer(entry).data
|
data = TimeEntrySerializer(entry).data
|
||||||
|
|
||||||
assert data["project"] == str(project.id)
|
self.assertEqual(data["project"], str(project.id))
|
||||||
assert data["project_details"]["name"] == "Legacy Project"
|
self.assertEqual(data["project_details"]["name"], "Legacy Project")
|
||||||
assert data["project_details"]["is_deleted"] is True
|
self.assertTrue(data["project_details"]["is_deleted"])
|
||||||
assert data["tags"] == [str(tag.id)]
|
self.assertEqual(data["tags"], [str(tag.id)])
|
||||||
assert data["tag_details"][0]["name"] == "Legacy Tag"
|
self.assertEqual(data["tag_details"][0]["name"], "Legacy Tag")
|
||||||
assert data["tag_details"][0]["is_deleted"] is True
|
self.assertTrue(data["tag_details"][0]["is_deleted"])
|
||||||
|
|||||||
@@ -1,61 +1,82 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
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, ProjectAccess, ProjectUserRate
|
||||||
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, WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
@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.member = User.objects.create_user(mobile="09121111112", password="secret123")
|
||||||
|
cls.workspace = Workspace.objects.create(name="Core", owner=cls.user)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
def test_create_time_entry_allows_only_one_running_timer_per_workspace(workspace_owner):
|
workspace=cls.workspace,
|
||||||
user, workspace = workspace_owner
|
user=cls.member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_time_entry_allows_only_one_running_timer_per_workspace(self):
|
||||||
create_time_entry(
|
create_time_entry(
|
||||||
user=user,
|
user=self.user,
|
||||||
workspace_id=workspace.id,
|
workspace_id=self.workspace.id,
|
||||||
start_time=timezone.now(),
|
start_time=timezone.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ValidationError):
|
with self.assertRaises(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() + timedelta(minutes=5),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_stop_time_entry_sets_end_time_and_duration(self):
|
||||||
def test_stop_time_entry_sets_end_time_and_duration(workspace_owner):
|
|
||||||
user, workspace = workspace_owner
|
|
||||||
entry = create_time_entry(
|
entry = create_time_entry(
|
||||||
user=user,
|
user=self.user,
|
||||||
workspace_id=workspace.id,
|
workspace_id=self.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_create_running_time_entry_defaults_start_time_to_server_now(self):
|
||||||
def test_update_time_entry_preserves_deleted_project_and_tags(workspace_owner):
|
before = timezone.now()
|
||||||
user, workspace = workspace_owner
|
|
||||||
project = Project.objects.create(workspace=workspace, name="Deleted project")
|
|
||||||
tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#0f172a")
|
|
||||||
entry = create_time_entry(
|
entry = create_time_entry(
|
||||||
user=user,
|
user=self.user,
|
||||||
workspace_id=workspace.id,
|
workspace_id=self.workspace.id,
|
||||||
|
)
|
||||||
|
after = timezone.now()
|
||||||
|
|
||||||
|
self.assertIsNone(entry.end_time)
|
||||||
|
self.assertGreaterEqual(entry.start_time, before)
|
||||||
|
self.assertLessEqual(entry.start_time, after)
|
||||||
|
|
||||||
|
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),
|
start_time=timezone.now() - timedelta(hours=1),
|
||||||
end_time=timezone.now(),
|
end_time=timezone.now(),
|
||||||
project=project,
|
project=project,
|
||||||
@@ -73,6 +94,47 @@ def test_update_time_entry_preserves_deleted_project_and_tags(workspace_owner):
|
|||||||
description="After delete",
|
description="After delete",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert updated_entry.description == "After delete"
|
self.assertEqual(updated_entry.description, "After delete")
|
||||||
assert updated_entry.project_id == project.id
|
self.assertEqual(updated_entry.project_id, project.id)
|
||||||
assert list(Tag.all_objects.filter(time_entries=updated_entry).values_list("id", flat=True)) == [tag.id]
|
self.assertEqual(
|
||||||
|
list(
|
||||||
|
Tag.all_objects.filter(time_entries=updated_entry).values_list(
|
||||||
|
"id",
|
||||||
|
flat=True,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[tag.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_billable_time_entry_uses_project_user_rate_override(self):
|
||||||
|
project = Project.objects.create(workspace=self.workspace, name="Override project")
|
||||||
|
ProjectAccess.objects.create(project=project, user=self.member)
|
||||||
|
WorkspaceUserRate.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.member,
|
||||||
|
hourly_rate=Decimal("10.00"),
|
||||||
|
currency="USD",
|
||||||
|
effective_from=self.workspace.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
ProjectUserRate.objects.create(
|
||||||
|
project=project,
|
||||||
|
user=self.member,
|
||||||
|
hourly_rate=Decimal("20.00"),
|
||||||
|
currency="EUR",
|
||||||
|
effective_from=self.workspace.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
entry = create_time_entry(
|
||||||
|
user=self.member,
|
||||||
|
workspace_id=self.workspace.id,
|
||||||
|
start_time=timezone.now() - timedelta(minutes=30),
|
||||||
|
end_time=timezone.now(),
|
||||||
|
project=project,
|
||||||
|
description="Billable work",
|
||||||
|
is_billable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(entry.hourly_rate, Decimal("20.00"))
|
||||||
|
self.assertEqual(entry.currency, "EUR")
|
||||||
|
|||||||
@@ -1,19 +1,50 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from apps.projects.models import Project, ProjectAccess
|
||||||
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
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
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),
|
||||||
|
current_timezone,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_time_entry_list_returns_grouped_payload_for_ended_entries(db):
|
class TimeEntryViewTests(APITestCase):
|
||||||
|
def test_create_running_time_entry_without_start_time_uses_server_time(self):
|
||||||
|
user = User.objects.create_user(mobile="09125555555", password="secret123")
|
||||||
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=user)
|
||||||
|
before = timezone.now()
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/time-entries/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(workspace.id),
|
||||||
|
"description": "Running work",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
after = timezone.now()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
entry = TimeEntry.objects.get(id=response.data["id"])
|
||||||
|
self.assertIsNone(entry.end_time)
|
||||||
|
self.assertGreaterEqual(entry.start_time, before)
|
||||||
|
self.assertLessEqual(entry.start_time, after)
|
||||||
|
self.assertIsInstance(response.data["start_time_ms"], int)
|
||||||
|
self.assertIsNone(response.data["end_time_ms"])
|
||||||
|
self.assertIsInstance(response.data["server_now_ms"], int)
|
||||||
|
|
||||||
|
def test_time_entry_list_returns_grouped_payload_for_ended_entries(self):
|
||||||
user = User.objects.create_user(mobile="09126666666", password="secret123")
|
user = User.objects.create_user(mobile="09126666666", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
|
|
||||||
@@ -31,10 +62,8 @@ def test_time_entry_list_returns_grouped_payload_for_ended_entries(db):
|
|||||||
start_time=make_aware(2026, 4, 24, 11, 0, 0),
|
start_time=make_aware(2026, 4, 24, 11, 0, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
client = APIClient()
|
self.client.force_authenticate(user=user)
|
||||||
client.force_authenticate(user=user)
|
response = self.client.get(
|
||||||
|
|
||||||
response = client.get(
|
|
||||||
"/api/time-entries/",
|
"/api/time-entries/",
|
||||||
{
|
{
|
||||||
"workspace": str(workspace.id),
|
"workspace": str(workspace.id),
|
||||||
@@ -44,15 +73,54 @@ def test_time_entry_list_returns_grouped_payload_for_ended_entries(db):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response.data["current_page_items_count"] == 1
|
self.assertEqual(response.data["current_page_items_count"], 1)
|
||||||
assert response.data["has_more"] is False
|
self.assertIsInstance(response.data["server_now_ms"], int)
|
||||||
assert len(response.data["groups"]) == 1
|
self.assertIn("server_now", response.data)
|
||||||
assert len(response.data["groups"][0]["days"]) == 1
|
self.assertFalse(response.data["has_more"])
|
||||||
assert response.data["groups"][0]["days"][0]["entries"][0]["id"] == str(first_entry.id)
|
self.assertEqual(len(response.data["groups"]), 1)
|
||||||
|
self.assertEqual(len(response.data["groups"][0]["days"]), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data["groups"][0]["days"][0]["entries"][0]["id"],
|
||||||
|
str(first_entry.id),
|
||||||
|
)
|
||||||
|
entry_payload = response.data["groups"][0]["days"][0]["entries"][0]
|
||||||
|
self.assertIsInstance(entry_payload["start_time_ms"], int)
|
||||||
|
self.assertIsInstance(entry_payload["end_time_ms"], int)
|
||||||
|
self.assertIsInstance(entry_payload["server_now_ms"], int)
|
||||||
|
|
||||||
|
def test_debug_time_returns_server_clock_payload(self):
|
||||||
|
user = User.objects.create_user(mobile="09126666667", password="secret123")
|
||||||
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
def test_time_entry_update_preserves_current_deleted_tags(db):
|
response = self.client.get("/api/time-entries/debug-time/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIsInstance(response.data["server_now_ms"], int)
|
||||||
|
self.assertIn("server_now", response.data)
|
||||||
|
|
||||||
|
def test_stop_running_time_entry_returns_server_epoch_fields(self):
|
||||||
|
user = User.objects.create_user(mobile="09126666668", password="secret123")
|
||||||
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
|
entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
description="Running work",
|
||||||
|
start_time=timezone.now() - timedelta(seconds=5),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=user)
|
||||||
|
response = self.client.post(f"/api/time-entries/{entry.id}/stop/", {}, format="json")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIsInstance(response.data["start_time_ms"], int)
|
||||||
|
self.assertIsInstance(response.data["end_time_ms"], int)
|
||||||
|
self.assertIsInstance(response.data["server_now_ms"], int)
|
||||||
|
entry.refresh_from_db()
|
||||||
|
self.assertIsNotNone(entry.duration)
|
||||||
|
self.assertGreaterEqual(entry.duration.total_seconds(), 5)
|
||||||
|
|
||||||
|
def test_time_entry_update_preserves_current_deleted_tags(self):
|
||||||
user = User.objects.create_user(mobile="09127777777", password="secret123")
|
user = User.objects.create_user(mobile="09127777777", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#475569")
|
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#475569")
|
||||||
@@ -66,10 +134,8 @@ def test_time_entry_update_preserves_current_deleted_tags(db):
|
|||||||
entry.tags.set([tag])
|
entry.tags.set([tag])
|
||||||
tag.delete()
|
tag.delete()
|
||||||
|
|
||||||
client = APIClient()
|
self.client.force_authenticate(user=user)
|
||||||
client.force_authenticate(user=user)
|
response = self.client.patch(
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/time-entries/{entry.id}/",
|
f"/api/time-entries/{entry.id}/",
|
||||||
{
|
{
|
||||||
"description": "Still editable",
|
"description": "Still editable",
|
||||||
@@ -78,15 +144,18 @@ def test_time_entry_update_preserves_current_deleted_tags(db):
|
|||||||
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):
|
||||||
def test_time_entry_update_rejects_new_deleted_tag_attachment(db):
|
|
||||||
user = User.objects.create_user(mobile="09128888888", password="secret123")
|
user = User.objects.create_user(mobile="09128888888", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
deleted_tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#475569")
|
deleted_tag = Tag.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
name="Deleted tag",
|
||||||
|
color="#475569",
|
||||||
|
)
|
||||||
deleted_tag.delete()
|
deleted_tag.delete()
|
||||||
entry = TimeEntry.objects.create(
|
entry = TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
@@ -96,25 +165,24 @@ def test_time_entry_update_rejects_new_deleted_tag_attachment(db):
|
|||||||
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
client = APIClient()
|
self.client.force_authenticate(user=user)
|
||||||
client.force_authenticate(user=user)
|
response = self.client.patch(
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/time-entries/{entry.id}/",
|
f"/api/time-entries/{entry.id}/",
|
||||||
{
|
{"tags": [str(deleted_tag.id)]},
|
||||||
"tags": [str(deleted_tag.id)],
|
|
||||||
},
|
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
self.assertEqual(response.status_code, 400)
|
||||||
assert "unavailable" in response.data["error"].lower()
|
self.assertIn("unavailable", response.data["error"].lower())
|
||||||
|
|
||||||
|
def test_time_entry_update_can_remove_current_deleted_tag(self):
|
||||||
def test_time_entry_update_can_remove_current_deleted_tag(db):
|
|
||||||
user = User.objects.create_user(mobile="09129999999", password="secret123")
|
user = User.objects.create_user(mobile="09129999999", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
deleted_tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#475569")
|
deleted_tag = Tag.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
name="Deleted tag",
|
||||||
|
color="#475569",
|
||||||
|
)
|
||||||
entry = TimeEntry.objects.create(
|
entry = TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
user=user,
|
user=user,
|
||||||
@@ -125,16 +193,71 @@ def test_time_entry_update_can_remove_current_deleted_tag(db):
|
|||||||
entry.tags.set([deleted_tag])
|
entry.tags.set([deleted_tag])
|
||||||
deleted_tag.delete()
|
deleted_tag.delete()
|
||||||
|
|
||||||
client = APIClient()
|
self.client.force_authenticate(user=user)
|
||||||
client.force_authenticate(user=user)
|
response = self.client.patch(
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/time-entries/{entry.id}/",
|
f"/api/time-entries/{entry.id}/",
|
||||||
|
{"tags": []},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["tags"], [])
|
||||||
|
|
||||||
|
def test_member_cannot_create_time_entry_for_inaccessible_project(self):
|
||||||
|
owner = User.objects.create_user(mobile="09120000001", password="secret123")
|
||||||
|
member = User.objects.create_user(mobile="09120000002", password="secret123")
|
||||||
|
workspace = Workspace.objects.create(name="Core", owner=owner)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
project = Project.objects.create(workspace=workspace, name="Restricted")
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=member)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/time-entries/",
|
||||||
{
|
{
|
||||||
"tags": [],
|
"workspace_id": str(workspace.id),
|
||||||
|
"project_id": str(project.id),
|
||||||
|
"description": "Blocked",
|
||||||
|
"start_time": make_aware(2026, 4, 24, 9, 0, 0).isoformat(),
|
||||||
|
"end_time": make_aware(2026, 4, 24, 10, 0, 0).isoformat(),
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 400)
|
||||||
assert response.data["tags"] == []
|
self.assertTrue(
|
||||||
|
any("Selected project is unavailable." in item["message"] for item in response.data["messages"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_member_can_create_time_entry_after_project_access_is_granted(self):
|
||||||
|
owner = User.objects.create_user(mobile="09120000011", password="secret123")
|
||||||
|
member = User.objects.create_user(mobile="09120000012", password="secret123")
|
||||||
|
workspace = Workspace.objects.create(name="Core", owner=owner)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
project = Project.objects.create(workspace=workspace, name="Accessible")
|
||||||
|
ProjectAccess.objects.create(project=project, user=member)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=member)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/time-entries/",
|
||||||
|
{
|
||||||
|
"workspace_id": str(workspace.id),
|
||||||
|
"project_id": str(project.id),
|
||||||
|
"description": "Allowed",
|
||||||
|
"start_time": make_aware(2026, 4, 24, 9, 0, 0).isoformat(),
|
||||||
|
"end_time": make_aware(2026, 4, 24, 10, 0, 0).isoformat(),
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(response.data["project"], str(project.id))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
from django.contrib.auth.forms import SetPasswordForm
|
from django.contrib.auth.forms import SetPasswordForm
|
||||||
|
from django.db import transaction
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
@@ -11,6 +12,7 @@ from unfold.decorators import action as unfold_action
|
|||||||
|
|
||||||
from core.admins.base import BaseAdmin, SoftDeleteListFilter
|
from core.admins.base import BaseAdmin, SoftDeleteListFilter
|
||||||
|
|
||||||
|
from apps.users.models import UserSocialAccount
|
||||||
from apps.users.services.forms import CustomUserChangeForm, CustomUserCreationForm
|
from apps.users.services.forms import CustomUserChangeForm, CustomUserCreationForm
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -21,6 +23,34 @@ class UserResource(resources.ModelResource):
|
|||||||
model = User
|
model = User
|
||||||
|
|
||||||
|
|
||||||
|
class UserSocialAccountInline(admin.TabularInline):
|
||||||
|
model = UserSocialAccount
|
||||||
|
fk_name = "user"
|
||||||
|
extra = 0
|
||||||
|
autocomplete_fields = ("user",)
|
||||||
|
fields = (
|
||||||
|
"provider",
|
||||||
|
"provider_user_id",
|
||||||
|
"email",
|
||||||
|
"email_verified",
|
||||||
|
"avatar_url",
|
||||||
|
"is_deleted",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
readonly_fields = (
|
||||||
|
"provider",
|
||||||
|
"provider_user_id",
|
||||||
|
"email",
|
||||||
|
"email_verified",
|
||||||
|
"avatar_url",
|
||||||
|
"is_deleted",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
show_change_link = True
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
class CustomUserAdmin(BaseUserAdmin, BaseAdmin):
|
class CustomUserAdmin(BaseUserAdmin, BaseAdmin):
|
||||||
model = User
|
model = User
|
||||||
@@ -136,6 +166,7 @@ class CustomUserAdmin(BaseUserAdmin, BaseAdmin):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
filter_horizontal = ("groups", "user_permissions")
|
filter_horizontal = ("groups", "user_permissions")
|
||||||
|
inlines = (UserSocialAccountInline,)
|
||||||
|
|
||||||
actions_row = [
|
actions_row = [
|
||||||
"reset_password_action",
|
"reset_password_action",
|
||||||
@@ -181,3 +212,53 @@ class CustomUserAdmin(BaseUserAdmin, BaseAdmin):
|
|||||||
@admin.action(description="Deactivate selected users")
|
@admin.action(description="Deactivate selected users")
|
||||||
def deactivate_users(self, request, queryset):
|
def deactivate_users(self, request, queryset):
|
||||||
queryset.update(is_active=False)
|
queryset.update(is_active=False)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserSocialAccount)
|
||||||
|
class UserSocialAccountAdmin(BaseAdmin):
|
||||||
|
list_display = (
|
||||||
|
"provider",
|
||||||
|
"provider_user_id",
|
||||||
|
"user",
|
||||||
|
"email",
|
||||||
|
"email_verified",
|
||||||
|
"created_at",
|
||||||
|
"is_deleted",
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
|
"provider_user_id",
|
||||||
|
"email",
|
||||||
|
"user__mobile",
|
||||||
|
"user__first_name",
|
||||||
|
"user__last_name",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
SoftDeleteListFilter,
|
||||||
|
"provider",
|
||||||
|
"email_verified",
|
||||||
|
"is_deleted",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
readonly_fields = (
|
||||||
|
"id",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"deleted_at",
|
||||||
|
)
|
||||||
|
autocomplete_fields = ("user",)
|
||||||
|
actions = (
|
||||||
|
"unlink_selected",
|
||||||
|
"hard_delete_selected",
|
||||||
|
"restore_selected",
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.action(description="Unlink selected social accounts")
|
||||||
|
def unlink_selected(self, request, queryset):
|
||||||
|
count = queryset.count()
|
||||||
|
with transaction.atomic():
|
||||||
|
queryset.hard_delete()
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"{count} social account link(s) permanently removed.",
|
||||||
|
level=messages.SUCCESS,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
import logging
|
from django.contrib.auth import get_user_model, password_validation
|
||||||
import random
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
import string
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.db import transaction
|
|
||||||
from django.utils import timezone
|
|
||||||
from django_redis import get_redis_connection
|
|
||||||
from drf_spectacular.utils import extend_schema_serializer
|
from 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.email_identity import normalize_email_identity
|
||||||
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__)
|
INVALID_MOBILE_FORMAT_MESSAGE = "فرمت شماره موبایل نادرست است."
|
||||||
|
INVALID_MOBILE_NUMBER_MESSAGE = "شماره موبایل معتبر نیست."
|
||||||
|
PASSWORD_MISMATCH_MESSAGE = "رمز عبور مطابقت ندارد."
|
||||||
|
NEW_PASSWORD_MISMATCH_MESSAGE = "رمز عبور جدید و تکرار آن مطابقت ندارند."
|
||||||
|
PASSWORD_REUSE_MESSAGE = "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد."
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_password_validation_error(password: str, *, user, field_name: str) -> None:
|
||||||
|
try:
|
||||||
|
password_validation.validate_password(password, user=user)
|
||||||
|
except DjangoValidationError as exc:
|
||||||
|
raise serializers.ValidationError({field_name: exc.messages[0] if len(exc.messages) == 1 else exc.messages})
|
||||||
|
|
||||||
|
|
||||||
class UserProfilePictureSerializer(BaseModelSerializer):
|
class UserProfilePictureSerializer(BaseModelSerializer):
|
||||||
@@ -51,10 +54,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": INVALID_MOBILE_FORMAT_MESSAGE})
|
||||||
|
|
||||||
if password != re_password:
|
if password != re_password:
|
||||||
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
|
raise serializers.ValidationError({"password": PASSWORD_MISMATCH_MESSAGE})
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -65,11 +68,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(INVALID_MOBILE_NUMBER_MESSAGE)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -80,7 +80,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(INVALID_MOBILE_FORMAT_MESSAGE)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -90,19 +90,57 @@ 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(INVALID_MOBILE_FORMAT_MESSAGE)
|
||||||
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(INVALID_MOBILE_FORMAT_MESSAGE)
|
||||||
|
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)
|
||||||
password = serializers.CharField(write_only=True)
|
password = serializers.CharField(write_only=True)
|
||||||
re_password = serializers.CharField(write_only=True)
|
re_password = serializers.CharField(write_only=True)
|
||||||
|
|
||||||
|
def validate_mobile(self, value):
|
||||||
|
if not value.isdigit() or len(value) != 11 or not value.startswith("09"):
|
||||||
|
raise serializers.ValidationError(INVALID_MOBILE_NUMBER_MESSAGE)
|
||||||
|
return value
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
if data.get("password") != data.get("re_password"):
|
mobile = data.get("mobile", "")
|
||||||
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
|
password = data.get("password", "")
|
||||||
|
re_password = data.get("re_password", "")
|
||||||
|
|
||||||
|
if password != re_password:
|
||||||
|
raise serializers.ValidationError({"password": PASSWORD_MISMATCH_MESSAGE})
|
||||||
|
|
||||||
|
user = User.objects.filter(mobile=mobile).only("password", "mobile", "first_name", "last_name", "email").first()
|
||||||
|
|
||||||
|
if user is not None:
|
||||||
|
_raise_password_validation_error(password, user=user, field_name="password")
|
||||||
|
|
||||||
|
if user.check_password(password):
|
||||||
|
raise serializers.ValidationError({"password": PASSWORD_REUSE_MESSAGE})
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -112,8 +150,22 @@ class ChangePasswordSerializer(serializers.Serializer):
|
|||||||
re_password = serializers.CharField(required=True, write_only=True)
|
re_password = serializers.CharField(required=True, write_only=True)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
if data.get("new_password") != data.get("re_password"):
|
old_password = data.get("old_password", "")
|
||||||
raise serializers.ValidationError({"new_password": "رمز عبور جدید و تکرار آن مطابقت ندارند."})
|
new_password = data.get("new_password", "")
|
||||||
|
re_password = data.get("re_password", "")
|
||||||
|
|
||||||
|
if new_password != re_password:
|
||||||
|
raise serializers.ValidationError({"new_password": NEW_PASSWORD_MISMATCH_MESSAGE})
|
||||||
|
|
||||||
|
request = self.context.get("request")
|
||||||
|
user = getattr(request, "user", None)
|
||||||
|
|
||||||
|
if old_password and old_password == new_password:
|
||||||
|
raise serializers.ValidationError({"new_password": PASSWORD_REUSE_MESSAGE})
|
||||||
|
|
||||||
|
if user is not None and getattr(user, "is_authenticated", False):
|
||||||
|
_raise_password_validation_error(new_password, user=user, field_name="new_password")
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -135,23 +187,46 @@ class UserProfileSerializer(BaseModelSerializer):
|
|||||||
full_name = serializers.ReadOnlyField()
|
full_name = serializers.ReadOnlyField()
|
||||||
age = serializers.ReadOnlyField()
|
age = serializers.ReadOnlyField()
|
||||||
|
|
||||||
|
def validate_email(self, value):
|
||||||
|
normalized = normalize_email_identity(value)
|
||||||
|
user = self.instance
|
||||||
|
|
||||||
|
if normalized is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
existing = User.objects.filter(email=normalized)
|
||||||
|
if user is not None:
|
||||||
|
existing = existing.exclude(pk=user.pk)
|
||||||
|
if existing.exists():
|
||||||
|
raise serializers.ValidationError("A user with this email already exists.")
|
||||||
|
return normalized
|
||||||
|
|
||||||
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",
|
||||||
|
"is_demo",
|
||||||
|
"demo_expires_at",
|
||||||
|
"full_name",
|
||||||
|
"age",
|
||||||
)
|
)
|
||||||
read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified")
|
read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified", "is_demo", "demo_expires_at")
|
||||||
|
|
||||||
|
|
||||||
class UserSearchSerializer(serializers.ModelSerializer):
|
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"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user