Compare commits
57 Commits
208e81139b
...
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 | |||
| 71924ce6fb | |||
| c8a118788b | |||
| 315f2ca728 | |||
| 76f02dc259 | |||
| afb1a55570 | |||
| 02c9c17c30 | |||
| 7bd60fd641 |
11
.coveragerc
Normal file
11
.coveragerc
Normal file
@@ -0,0 +1,11 @@
|
||||
[run]
|
||||
branch = True
|
||||
source =
|
||||
apps
|
||||
omit =
|
||||
*/migrations/*
|
||||
*/tests/*
|
||||
|
||||
[report]
|
||||
show_missing = True
|
||||
skip_covered = False
|
||||
@@ -40,5 +40,9 @@ CELERY_RESULT_BACKEND=
|
||||
LANGUAGE_CODE=en-us
|
||||
TIME_ZONE=Asia/Tehran
|
||||
|
||||
SMS_APIKEY=
|
||||
BASE_URL=
|
||||
SMS_APIKEY=
|
||||
BASE_URL=
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
GOOGLE_OAUTH_REDIRECT_URI=http://localhost:8000/api/users/oauth/google/callback/
|
||||
GOOGLE_OAUTH_FRONTEND_CALLBACK_URL=http://localhost:5173/auth/google/callback
|
||||
|
||||
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"
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -20,9 +20,13 @@ staticfiles/
|
||||
# Migrations
|
||||
**/migrations/*.pyc
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
!apps/logs/
|
||||
!apps/logs/**
|
||||
apps/logs/**/__pycache__/
|
||||
apps/logs/**/*.pyc
|
||||
|
||||
# IDE / Editor
|
||||
.vscode/
|
||||
|
||||
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
|
||||
@@ -6,6 +6,7 @@ from apps.workspaces.services import (
|
||||
CLIENTS_DELETE,
|
||||
CLIENTS_EDIT,
|
||||
CLIENTS_VIEW,
|
||||
can_delete_workspace_object,
|
||||
has_workspace_capability,
|
||||
)
|
||||
|
||||
@@ -43,4 +44,6 @@ class IsClientWorkspaceMember(permissions.BasePermission):
|
||||
"partial_update": CLIENTS_EDIT,
|
||||
"destroy": CLIENTS_DELETE,
|
||||
}.get(view.action, CLIENTS_VIEW)
|
||||
if view.action == "destroy":
|
||||
return can_delete_workspace_object(request.user, obj, CLIENTS_DELETE)
|
||||
return has_workspace_capability(request.user, obj.workspace, capability)
|
||||
|
||||
@@ -3,32 +3,65 @@ from apps.clients.models import Client
|
||||
from core.serializers.base import BaseModelSerializer
|
||||
|
||||
|
||||
class ClientSerializer(BaseModelSerializer):
|
||||
class ClientSerializer(BaseModelSerializer):
|
||||
"""
|
||||
Serializer for retrieving and representing client details.
|
||||
"""
|
||||
class Meta:
|
||||
model = Client
|
||||
fields = BaseModelSerializer.Meta.fields + (
|
||||
"workspace",
|
||||
"name",
|
||||
"notes",
|
||||
)
|
||||
read_only_fields = fields
|
||||
fields = BaseModelSerializer.Meta.fields + (
|
||||
"workspace",
|
||||
"name",
|
||||
"notes",
|
||||
"thumbnail",
|
||||
)
|
||||
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):
|
||||
"""
|
||||
Serializer for handling input data during client creation.
|
||||
"""
|
||||
workspace_id = serializers.UUIDField()
|
||||
name = serializers.CharField(max_length=255)
|
||||
notes = serializers.CharField(allow_blank=True, required=False, default="")
|
||||
workspace_id = serializers.UUIDField()
|
||||
name = serializers.CharField(max_length=255)
|
||||
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):
|
||||
"""
|
||||
Serializer for handling input data during client updates.
|
||||
"""
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
notes = serializers.CharField(allow_blank=True, required=False)
|
||||
name = serializers.CharField(max_length=255, 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)
|
||||
|
||||
@@ -61,12 +61,13 @@ class ClientViewSet(ModelViewSet):
|
||||
|
||||
client = create_client(
|
||||
user=request.user,
|
||||
workspace_id=serializer.validated_data["workspace_id"],
|
||||
name=serializer.validated_data["name"],
|
||||
notes=serializer.validated_data.get("notes", "")
|
||||
)
|
||||
|
||||
output_serializer = ClientSerializer(client)
|
||||
workspace_id=serializer.validated_data["workspace_id"],
|
||||
name=serializer.validated_data["name"],
|
||||
notes=serializer.validated_data.get("notes", ""),
|
||||
thumbnail=serializer.validated_data.get("thumbnail"),
|
||||
)
|
||||
|
||||
output_serializer = ClientSerializer(client, context={"request": request})
|
||||
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
@@ -80,12 +81,14 @@ class ClientViewSet(ModelViewSet):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
updated_client = update_client(
|
||||
client=client,
|
||||
name=serializer.validated_data.get("name"),
|
||||
notes=serializer.validated_data.get("notes")
|
||||
)
|
||||
|
||||
output_serializer = ClientSerializer(updated_client)
|
||||
client=client,
|
||||
name=serializer.validated_data.get("name"),
|
||||
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, context={"request": request})
|
||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
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/'),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,6 @@
|
||||
from django.db import models
|
||||
from apps.logs.services import build_workspace_log_metadata
|
||||
from apps.logs.services.constants import SECTION_CLIENTS
|
||||
from apps.workspaces.models import Workspace
|
||||
|
||||
from core.models.base import BaseModel
|
||||
@@ -15,6 +17,8 @@ class Client(BaseModel):
|
||||
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
thumbnail = models.ImageField(upload_to="profile/clients/", blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "client"
|
||||
ordering = ("-updated_at", "-created_at")
|
||||
@@ -32,3 +36,11 @@ class Client(BaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_additional_data(self):
|
||||
return build_workspace_log_metadata(
|
||||
section=SECTION_CLIENTS,
|
||||
workspace_id=self.workspace_id,
|
||||
target_id=self.id,
|
||||
target_label=self.name,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ from apps.clients.models import Client
|
||||
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.
|
||||
"""
|
||||
@@ -19,14 +19,17 @@ def create_client(user, workspace_id, name, notes=""):
|
||||
if Client.objects.filter(workspace_id=workspace_id, name=name, is_deleted=False).exists():
|
||||
raise ValidationError({"name": "مشتری با این نام در این فضای کاری وجود دارد."})
|
||||
|
||||
return Client.objects.create(
|
||||
workspace_id=workspace_id,
|
||||
name=name,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
|
||||
def update_client(client, name=None, notes=None):
|
||||
return Client.objects.create(
|
||||
workspace_id=workspace_id,
|
||||
name=name,
|
||||
notes=notes,
|
||||
thumbnail=thumbnail,
|
||||
created_by=user,
|
||||
updated_by=user,
|
||||
)
|
||||
|
||||
|
||||
def update_client(client, name=None, notes=None, thumbnail=None, clear_thumbnail=False):
|
||||
"""
|
||||
Updates an existing client while validating name uniqueness within the workspace.
|
||||
"""
|
||||
@@ -35,8 +38,20 @@ def update_client(client, name=None, notes=None):
|
||||
raise ValidationError({"name": "مشتری با این نام در این فضای کاری وجود دارد."})
|
||||
client.name = name
|
||||
|
||||
if notes is not None:
|
||||
client.notes = notes
|
||||
|
||||
client.save(update_fields=["name", "notes", "updated_at"])
|
||||
return client
|
||||
if notes is not None:
|
||||
client.notes = notes
|
||||
|
||||
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
|
||||
|
||||
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())
|
||||
0
apps/logs/__init__.py
Normal file
0
apps/logs/__init__.py
Normal file
2
apps/logs/admin.py
Normal file
2
apps/logs/admin.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Admin registrations for apps.logs live on django-auditlog."""
|
||||
|
||||
12
apps/logs/api/permissions.py
Normal file
12
apps/logs/api/permissions.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from apps.logs.services import WORKSPACE_LOGS_VIEW
|
||||
from apps.workspaces.services import has_workspace_capability
|
||||
|
||||
|
||||
def enforce_workspace_log_access(user, workspace) -> None:
|
||||
if not user or not user.is_authenticated:
|
||||
raise PermissionDenied("Authentication credentials were not provided.")
|
||||
if not has_workspace_capability(user, workspace, WORKSPACE_LOGS_VIEW):
|
||||
raise PermissionDenied("You do not have permission to view workspace logs.")
|
||||
|
||||
32
apps/logs/api/serializers.py
Normal file
32
apps/logs/api/serializers.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.logs.services import LOG_EVENTS, LOG_SECTIONS
|
||||
from apps.logs.services.query import (
|
||||
serialize_workspace_log_detail,
|
||||
serialize_workspace_log_list_item,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceLogQuerySerializer(serializers.Serializer):
|
||||
workspace = serializers.UUIDField()
|
||||
section = serializers.ChoiceField(choices=LOG_SECTIONS, required=False)
|
||||
actor = serializers.UUIDField(required=False)
|
||||
event = serializers.ChoiceField(choices=LOG_EVENTS, required=False)
|
||||
search = serializers.CharField(required=False, allow_blank=True, trim_whitespace=True)
|
||||
from_date = serializers.CharField(required=False, allow_blank=True, source="from")
|
||||
to_date = serializers.CharField(required=False, allow_blank=True, source="to")
|
||||
ordering = serializers.ChoiceField(
|
||||
choices=("timestamp", "-timestamp"),
|
||||
required=False,
|
||||
default="-timestamp",
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceLogListSerializer(serializers.Serializer):
|
||||
def to_representation(self, instance):
|
||||
return serialize_workspace_log_list_item(instance)
|
||||
|
||||
|
||||
class WorkspaceLogDetailSerializer(serializers.Serializer):
|
||||
def to_representation(self, instance):
|
||||
return serialize_workspace_log_detail(instance)
|
||||
12
apps/logs/api/urls.py
Normal file
12
apps/logs/api/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.logs.api.views import WorkspaceLogViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"", WorkspaceLogViewSet, basename="workspace-logs")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
80
apps/logs/api/views.py
Normal file
80
apps/logs/api/views.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from auditlog.models import LogEntry
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.logs.api.permissions import enforce_workspace_log_access
|
||||
from apps.logs.api.serializers import (
|
||||
WorkspaceLogDetailSerializer,
|
||||
WorkspaceLogListSerializer,
|
||||
WorkspaceLogQuerySerializer,
|
||||
)
|
||||
from apps.logs.services import (
|
||||
WorkspaceLogFilters,
|
||||
filter_workspace_logs,
|
||||
get_log_workspace_id,
|
||||
is_visible_workspace_log,
|
||||
parse_filter_datetime,
|
||||
)
|
||||
from apps.workspaces.models import Workspace
|
||||
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
||||
|
||||
|
||||
class WorkspaceLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
pagination_class = CustomLimitOffsetPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return LogEntry.objects.select_related("actor", "content_type").order_by("-timestamp")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "retrieve":
|
||||
return WorkspaceLogDetailSerializer
|
||||
return WorkspaceLogListSerializer
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
query_data = request.query_params.copy()
|
||||
if "from" in query_data:
|
||||
query_data["from_date"] = query_data.get("from")
|
||||
if "to" in query_data:
|
||||
query_data["to_date"] = query_data.get("to")
|
||||
|
||||
query_serializer = WorkspaceLogQuerySerializer(data=query_data)
|
||||
query_serializer.is_valid(raise_exception=True)
|
||||
filters_data = query_serializer.validated_data
|
||||
|
||||
workspace = get_object_or_404(
|
||||
Workspace,
|
||||
id=filters_data["workspace"],
|
||||
is_deleted=False,
|
||||
)
|
||||
enforce_workspace_log_access(request.user, workspace)
|
||||
|
||||
filters = WorkspaceLogFilters(
|
||||
workspace_id=str(workspace.id),
|
||||
section=filters_data.get("section"),
|
||||
actor_id=str(filters_data["actor"]) if filters_data.get("actor") else None,
|
||||
event=filters_data.get("event"),
|
||||
search=filters_data.get("search") or None,
|
||||
from_value=parse_filter_datetime(query_data.get("from")),
|
||||
to_value=parse_filter_datetime(query_data.get("to"), is_end=True),
|
||||
ordering=filters_data.get("ordering", "-timestamp"),
|
||||
)
|
||||
|
||||
queryset = filter_workspace_logs(self.get_queryset(), filters)
|
||||
page = self.paginate_queryset(queryset)
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
entry = self.get_object()
|
||||
workspace_id = get_log_workspace_id(entry)
|
||||
if not workspace_id:
|
||||
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
|
||||
enforce_workspace_log_access(request.user, workspace)
|
||||
if not is_visible_workspace_log(entry):
|
||||
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = self.get_serializer(entry)
|
||||
return Response(serializer.data)
|
||||
8
apps/logs/apps.py
Normal file
8
apps/logs/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LogsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.logs"
|
||||
verbose_name = "Workspace Logs"
|
||||
|
||||
29
apps/logs/middlewares.py
Normal file
29
apps/logs/middlewares.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
|
||||
class JWTRequestActorMiddleware:
|
||||
"""
|
||||
Resolve Bearer tokens before DRF runs so middleware-driven audit hooks
|
||||
can see the authenticated actor on API requests.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
self.authenticator = JWTAuthentication()
|
||||
|
||||
def __call__(self, request):
|
||||
current_user = getattr(request, "user", None)
|
||||
if not getattr(current_user, "is_authenticated", False):
|
||||
try:
|
||||
authenticated = self.authenticator.authenticate(request)
|
||||
except Exception:
|
||||
authenticated = None
|
||||
|
||||
if authenticated is not None:
|
||||
request.user = authenticated[0]
|
||||
elif current_user is None:
|
||||
request.user = AnonymousUser()
|
||||
|
||||
return self.get_response(request)
|
||||
|
||||
0
apps/logs/migrations/__init__.py
Normal file
0
apps/logs/migrations/__init__.py
Normal file
2
apps/logs/models.py
Normal file
2
apps/logs/models.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Workspace logs are backed by django-auditlog models."""
|
||||
|
||||
54
apps/logs/services/__init__.py
Normal file
54
apps/logs/services/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from apps.logs.services.constants import (
|
||||
EVENT_ACTIVATE,
|
||||
EVENT_ARCHIVE,
|
||||
EVENT_CREATE,
|
||||
EVENT_DEACTIVATE,
|
||||
EVENT_DELETE,
|
||||
EVENT_RESTORE,
|
||||
EVENT_UNARCHIVE,
|
||||
EVENT_UPDATE,
|
||||
LOG_EVENTS,
|
||||
LOG_SECTIONS,
|
||||
WORKSPACE_LOGS_VIEW,
|
||||
)
|
||||
from apps.logs.services.metadata import build_workspace_log_metadata
|
||||
from apps.logs.services.query import (
|
||||
WorkspaceLogFilters,
|
||||
build_log_target_payload,
|
||||
filter_workspace_logs,
|
||||
get_log_change_rows,
|
||||
get_log_section,
|
||||
get_log_workspace_id,
|
||||
is_visible_workspace_log,
|
||||
normalize_log_event,
|
||||
parse_filter_datetime,
|
||||
serialize_workspace_log_detail,
|
||||
serialize_workspace_log_list_item,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"WORKSPACE_LOGS_VIEW",
|
||||
"LOG_SECTIONS",
|
||||
"LOG_EVENTS",
|
||||
"EVENT_CREATE",
|
||||
"EVENT_UPDATE",
|
||||
"EVENT_DELETE",
|
||||
"EVENT_RESTORE",
|
||||
"EVENT_ARCHIVE",
|
||||
"EVENT_UNARCHIVE",
|
||||
"EVENT_ACTIVATE",
|
||||
"EVENT_DEACTIVATE",
|
||||
"WorkspaceLogFilters",
|
||||
"build_workspace_log_metadata",
|
||||
"get_log_section",
|
||||
"get_log_workspace_id",
|
||||
"normalize_log_event",
|
||||
"get_log_change_rows",
|
||||
"build_log_target_payload",
|
||||
"filter_workspace_logs",
|
||||
"is_visible_workspace_log",
|
||||
"serialize_workspace_log_list_item",
|
||||
"serialize_workspace_log_detail",
|
||||
"parse_filter_datetime",
|
||||
]
|
||||
|
||||
54
apps/logs/services/constants.py
Normal file
54
apps/logs/services/constants.py
Normal file
@@ -0,0 +1,54 @@
|
||||
WORKSPACE_LOGS_VIEW = "workspace.logs.view"
|
||||
|
||||
SECTION_WORKSPACE = "workspace"
|
||||
SECTION_WORKSPACE_MEMBERS = "workspace_members"
|
||||
SECTION_CLIENTS = "clients"
|
||||
SECTION_PROJECTS = "projects"
|
||||
SECTION_TAGS = "tags"
|
||||
SECTION_TIME_ENTRIES = "time_entries"
|
||||
SECTION_RATES = "rates"
|
||||
SECTION_REPORT_EXPORTS = "report_exports"
|
||||
|
||||
LOG_SECTIONS = (
|
||||
SECTION_WORKSPACE,
|
||||
SECTION_WORKSPACE_MEMBERS,
|
||||
SECTION_CLIENTS,
|
||||
SECTION_PROJECTS,
|
||||
SECTION_TAGS,
|
||||
SECTION_TIME_ENTRIES,
|
||||
SECTION_RATES,
|
||||
SECTION_REPORT_EXPORTS,
|
||||
)
|
||||
|
||||
EVENT_CREATE = "create"
|
||||
EVENT_UPDATE = "update"
|
||||
EVENT_DELETE = "delete"
|
||||
EVENT_RESTORE = "restore"
|
||||
EVENT_ARCHIVE = "archive"
|
||||
EVENT_UNARCHIVE = "unarchive"
|
||||
EVENT_ACTIVATE = "activate"
|
||||
EVENT_DEACTIVATE = "deactivate"
|
||||
|
||||
LOG_EVENTS = (
|
||||
EVENT_CREATE,
|
||||
EVENT_UPDATE,
|
||||
EVENT_DELETE,
|
||||
EVENT_RESTORE,
|
||||
EVENT_ARCHIVE,
|
||||
EVENT_UNARCHIVE,
|
||||
EVENT_ACTIVATE,
|
||||
EVENT_DEACTIVATE,
|
||||
)
|
||||
|
||||
SECTION_BY_MODEL_LABEL = {
|
||||
"workspaces.workspace": SECTION_WORKSPACE,
|
||||
"workspaces.workspacemembership": SECTION_WORKSPACE_MEMBERS,
|
||||
"workspaces.workspaceuserrate": SECTION_RATES,
|
||||
"clients.client": SECTION_CLIENTS,
|
||||
"projects.project": SECTION_PROJECTS,
|
||||
"tags.tag": SECTION_TAGS,
|
||||
"time_entries.timeentry": SECTION_TIME_ENTRIES,
|
||||
"reports.reportexportjob": SECTION_REPORT_EXPORTS,
|
||||
}
|
||||
|
||||
TRACKED_MODEL_LABELS = tuple(SECTION_BY_MODEL_LABEL.keys())
|
||||
27
apps/logs/services/metadata.py
Normal file
27
apps/logs/services/metadata.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def build_workspace_log_metadata(
|
||||
*,
|
||||
section: str,
|
||||
workspace_id,
|
||||
target_id,
|
||||
target_label: str,
|
||||
extra: dict | None = None,
|
||||
) -> dict:
|
||||
metadata = {
|
||||
"workspace_id": str(workspace_id),
|
||||
"section": section,
|
||||
"target_id": str(target_id),
|
||||
"target_label": target_label,
|
||||
}
|
||||
if extra:
|
||||
metadata.update(
|
||||
{
|
||||
key: value
|
||||
for key, value in extra.items()
|
||||
if value is not None
|
||||
}
|
||||
)
|
||||
return metadata
|
||||
|
||||
297
apps/logs/services/query.py
Normal file
297
apps/logs/services/query.py
Normal file
@@ -0,0 +1,297 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time
|
||||
|
||||
from django.db.models import Q, QuerySet, TextField
|
||||
from django.db.models.functions import Cast
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date, parse_datetime
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
|
||||
from apps.logs.services.constants import (
|
||||
EVENT_ACTIVATE,
|
||||
EVENT_ARCHIVE,
|
||||
EVENT_CREATE,
|
||||
EVENT_DEACTIVATE,
|
||||
EVENT_DELETE,
|
||||
EVENT_RESTORE,
|
||||
EVENT_UNARCHIVE,
|
||||
EVENT_UPDATE,
|
||||
LOG_EVENTS,
|
||||
LOG_SECTIONS,
|
||||
SECTION_BY_MODEL_LABEL,
|
||||
SECTION_REPORT_EXPORTS,
|
||||
SECTION_WORKSPACE_MEMBERS,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceLogFilters:
|
||||
workspace_id: str
|
||||
section: str | None = None
|
||||
actor_id: str | None = None
|
||||
event: str | None = None
|
||||
search: str | None = None
|
||||
from_value: datetime | None = None
|
||||
to_value: datetime | None = None
|
||||
ordering: str = "-timestamp"
|
||||
|
||||
|
||||
def get_log_model_label(entry: LogEntry) -> str | None:
|
||||
if not entry.content_type_id:
|
||||
return None
|
||||
return f"{entry.content_type.app_label}.{entry.content_type.model}"
|
||||
|
||||
|
||||
def get_log_section(entry: LogEntry) -> str | None:
|
||||
additional_data = entry.additional_data or {}
|
||||
section = additional_data.get("section")
|
||||
if section in LOG_SECTIONS:
|
||||
return section
|
||||
return SECTION_BY_MODEL_LABEL.get(get_log_model_label(entry) or "")
|
||||
|
||||
|
||||
def get_log_workspace_id(entry: LogEntry) -> str | None:
|
||||
additional_data = entry.additional_data or {}
|
||||
workspace_id = additional_data.get("workspace_id")
|
||||
return str(workspace_id) if workspace_id else None
|
||||
|
||||
|
||||
def normalize_log_event(entry: LogEntry) -> str:
|
||||
changes = entry.changes or {}
|
||||
|
||||
is_deleted = changes.get("is_deleted")
|
||||
if isinstance(is_deleted, (list, tuple)) and len(is_deleted) >= 2:
|
||||
if is_deleted[0] in {False, "False", "false", None, "None"} and is_deleted[1] in {True, "True", "true"}:
|
||||
return EVENT_DELETE
|
||||
if is_deleted[0] in {True, "True", "true"} and is_deleted[1] in {False, "False", "false", None, "None"}:
|
||||
return EVENT_RESTORE
|
||||
|
||||
is_archived = changes.get("is_archived")
|
||||
if isinstance(is_archived, (list, tuple)) and len(is_archived) >= 2:
|
||||
if is_archived[0] in {False, "False", "false", None, "None"} and is_archived[1] in {True, "True", "true"}:
|
||||
return EVENT_ARCHIVE
|
||||
if is_archived[0] in {True, "True", "true"} and is_archived[1] in {False, "False", "false", None, "None"}:
|
||||
return EVENT_UNARCHIVE
|
||||
|
||||
is_active = changes.get("is_active")
|
||||
if isinstance(is_active, (list, tuple)) and len(is_active) >= 2:
|
||||
if is_active[0] in {False, "False", "false", None, "None"} and is_active[1] in {True, "True", "true"}:
|
||||
return EVENT_ACTIVATE
|
||||
if is_active[0] in {True, "True", "true"} and is_active[1] in {False, "False", "false", None, "None"}:
|
||||
return EVENT_DEACTIVATE
|
||||
|
||||
if entry.action == LogEntry.Action.CREATE:
|
||||
return EVENT_CREATE
|
||||
return EVENT_UPDATE
|
||||
|
||||
|
||||
def _stringify_change_value(value) -> str | None:
|
||||
if value in (None, "", "None"):
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, list):
|
||||
return ", ".join(str(item) for item in value if item not in (None, ""))
|
||||
return str(value)
|
||||
|
||||
|
||||
def get_log_change_rows(entry: LogEntry) -> list[dict]:
|
||||
changes = entry.changes or {}
|
||||
display_changes = {}
|
||||
try:
|
||||
display_changes = entry.changes_display_dict or {}
|
||||
except Exception:
|
||||
display_changes = {}
|
||||
|
||||
rows = []
|
||||
for field_name, raw_value in changes.items():
|
||||
if isinstance(raw_value, dict) and raw_value.get("type") == "m2m":
|
||||
operation = raw_value.get("operation", "update")
|
||||
objects = [str(item) for item in raw_value.get("objects", [])]
|
||||
rows.append(
|
||||
{
|
||||
"field": field_name,
|
||||
"label": field_name.replace("_", " ").title(),
|
||||
"change_type": "m2m",
|
||||
"operation": operation,
|
||||
"old_value": None,
|
||||
"new_value": ", ".join(objects) if objects else None,
|
||||
"summary": f"{operation.title()}: {', '.join(objects)}" if objects else operation.title(),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
old_value = None
|
||||
new_value = None
|
||||
display_value = display_changes.get(field_name.replace("_", " ").title()) or display_changes.get(field_name)
|
||||
if isinstance(display_value, (list, tuple)) and len(display_value) >= 2:
|
||||
old_value = _stringify_change_value(display_value[0])
|
||||
new_value = _stringify_change_value(display_value[1])
|
||||
elif isinstance(raw_value, (list, tuple)) and len(raw_value) >= 2:
|
||||
old_value = _stringify_change_value(raw_value[0])
|
||||
new_value = _stringify_change_value(raw_value[1])
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"field": field_name,
|
||||
"label": field_name.replace("_", " ").title(),
|
||||
"change_type": "field",
|
||||
"operation": "replace",
|
||||
"old_value": old_value,
|
||||
"new_value": new_value,
|
||||
"summary": f"{field_name}: {old_value or '-'} -> {new_value or '-'}",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def get_log_preview_changes(entry: LogEntry, limit: int = 3) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"field": row["field"],
|
||||
"label": row["label"],
|
||||
"summary": row["summary"],
|
||||
}
|
||||
for row in get_log_change_rows(entry)[:limit]
|
||||
]
|
||||
|
||||
|
||||
def is_visible_workspace_log(entry: LogEntry) -> bool:
|
||||
if entry.actor_id is None:
|
||||
return False
|
||||
|
||||
section = get_log_section(entry)
|
||||
if section not in LOG_SECTIONS:
|
||||
return False
|
||||
|
||||
additional_data = entry.additional_data or {}
|
||||
if section == SECTION_REPORT_EXPORTS and entry.actor_id is None:
|
||||
return False
|
||||
|
||||
if (
|
||||
section == SECTION_WORKSPACE_MEMBERS
|
||||
and normalize_log_event(entry) == EVENT_CREATE
|
||||
and additional_data.get("canonical_owner_membership") is True
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def filter_workspace_logs(queryset: QuerySet, filters: WorkspaceLogFilters) -> QuerySet:
|
||||
queryset = queryset.filter(additional_data__workspace_id=filters.workspace_id)
|
||||
|
||||
if filters.section:
|
||||
queryset = queryset.filter(additional_data__section=filters.section)
|
||||
|
||||
if filters.actor_id:
|
||||
queryset = queryset.filter(actor_id=filters.actor_id)
|
||||
|
||||
if filters.from_value:
|
||||
queryset = queryset.filter(timestamp__gte=filters.from_value)
|
||||
|
||||
if filters.to_value:
|
||||
queryset = queryset.filter(timestamp__lte=filters.to_value)
|
||||
|
||||
queryset = queryset.annotate(changes_json_text=Cast("changes", TextField()))
|
||||
|
||||
if filters.search:
|
||||
queryset = queryset.filter(
|
||||
Q(object_repr__icontains=filters.search)
|
||||
| Q(changes_text__icontains=filters.search)
|
||||
| Q(changes_json_text__icontains=filters.search)
|
||||
| Q(actor__first_name__icontains=filters.search)
|
||||
| Q(actor__last_name__icontains=filters.search)
|
||||
| Q(actor__mobile__icontains=filters.search)
|
||||
| Q(additional_data__target_label__icontains=filters.search)
|
||||
)
|
||||
|
||||
queryset = queryset.order_by(filters.ordering)
|
||||
if filters.event:
|
||||
matching_ids = [
|
||||
entry.id
|
||||
for entry in queryset
|
||||
if normalize_log_event(entry) == filters.event and is_visible_workspace_log(entry)
|
||||
]
|
||||
return queryset.filter(id__in=matching_ids)
|
||||
|
||||
matching_ids = [entry.id for entry in queryset if is_visible_workspace_log(entry)]
|
||||
return queryset.filter(id__in=matching_ids)
|
||||
|
||||
|
||||
def build_log_actor_payload(entry: LogEntry) -> dict | None:
|
||||
actor = entry.actor
|
||||
if not actor:
|
||||
return None
|
||||
full_name = actor.full_name if getattr(actor, "full_name", "").strip() else actor.mobile
|
||||
return {
|
||||
"id": str(actor.id),
|
||||
"full_name": full_name,
|
||||
"mobile": actor.mobile,
|
||||
"profile_picture": actor.profile_picture.url if getattr(actor, "profile_picture", None) else None,
|
||||
}
|
||||
|
||||
|
||||
def build_log_target_payload(entry: LogEntry) -> dict:
|
||||
additional_data = entry.additional_data or {}
|
||||
return {
|
||||
"id": str(additional_data.get("target_id") or entry.object_pk),
|
||||
"name": additional_data.get("target_label") or entry.object_repr,
|
||||
"section": get_log_section(entry),
|
||||
"workspace_id": get_log_workspace_id(entry),
|
||||
}
|
||||
|
||||
|
||||
def serialize_workspace_log_list_item(entry: LogEntry) -> dict:
|
||||
rows = get_log_change_rows(entry)
|
||||
return {
|
||||
"id": entry.id,
|
||||
"timestamp": timezone.localtime(entry.timestamp).isoformat(),
|
||||
"section": get_log_section(entry),
|
||||
"model": get_log_model_label(entry),
|
||||
"event": normalize_log_event(entry),
|
||||
"audit_action": entry.get_action_display(),
|
||||
"actor": build_log_actor_payload(entry),
|
||||
"target": build_log_target_payload(entry),
|
||||
"changed_fields": [row["field"] for row in rows],
|
||||
"preview_changes": get_log_preview_changes(entry),
|
||||
}
|
||||
|
||||
|
||||
def serialize_workspace_log_detail(entry: LogEntry) -> dict:
|
||||
payload = serialize_workspace_log_list_item(entry)
|
||||
payload.update(
|
||||
{
|
||||
"remote_addr": entry.remote_addr,
|
||||
"changes": get_log_change_rows(entry),
|
||||
"raw_changes": entry.changes or {},
|
||||
"serialized_snapshot": entry.serialized_data,
|
||||
"additional_data": entry.additional_data or {},
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def parse_filter_datetime(value: str | None, *, is_end: bool = False) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
parsed_datetime = parse_datetime(value)
|
||||
if parsed_datetime is not None:
|
||||
if timezone.is_naive(parsed_datetime):
|
||||
return timezone.make_aware(parsed_datetime, timezone.get_current_timezone())
|
||||
return parsed_datetime
|
||||
|
||||
parsed_date = parse_date(value)
|
||||
if parsed_date is None:
|
||||
return None
|
||||
|
||||
boundary = time.max if is_end else time.min
|
||||
return timezone.make_aware(
|
||||
datetime.combine(parsed_date, boundary),
|
||||
timezone.get_current_timezone(),
|
||||
)
|
||||
|
||||
1
apps/logs/tests/__init__.py
Normal file
1
apps/logs/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
161
apps/logs/tests/test_views.py
Normal file
161
apps/logs/tests/test_views.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from auditlog.models import LogEntry
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from apps.reports.models import ReportExportJob
|
||||
from apps.users.models import User
|
||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||
|
||||
|
||||
class WorkspaceLogViewTests(APITestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.owner = cls._user(1)
|
||||
cls.admin = cls._user(2)
|
||||
cls.member = cls._user(3)
|
||||
cls.outsider = cls._user(4)
|
||||
|
||||
cls.workspace = Workspace.objects.create(
|
||||
name="Logs WS",
|
||||
description="",
|
||||
owner=cls.owner,
|
||||
)
|
||||
WorkspaceMembership.objects.create(
|
||||
workspace=cls.workspace,
|
||||
user=cls.admin,
|
||||
role=WorkspaceMembership.Role.ADMIN,
|
||||
is_active=True,
|
||||
)
|
||||
WorkspaceMembership.objects.create(
|
||||
workspace=cls.workspace,
|
||||
user=cls.member,
|
||||
role=WorkspaceMembership.Role.MEMBER,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _user(index):
|
||||
return User.objects.create_user(
|
||||
mobile=f"093355500{index:02d}",
|
||||
password="secret123",
|
||||
first_name=f"Log{index}",
|
||||
last_name="User",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _auth_headers(user):
|
||||
token = str(AccessToken.for_user(user))
|
||||
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
||||
|
||||
def _create_tag(self, user, *, name="Audit Tag"):
|
||||
return self.client.post(
|
||||
"/api/tags/",
|
||||
{
|
||||
"workspace_id": str(self.workspace.id),
|
||||
"name": name,
|
||||
"color": "#123456",
|
||||
},
|
||||
format="json",
|
||||
**self._auth_headers(user),
|
||||
)
|
||||
|
||||
def test_owner_and_admin_can_list_workspace_logs(self):
|
||||
create_response = self._create_tag(self.owner)
|
||||
self.assertEqual(create_response.status_code, 201)
|
||||
|
||||
owner_response = self.client.get(
|
||||
f"/api/logs/?workspace={self.workspace.id}",
|
||||
**self._auth_headers(self.owner),
|
||||
)
|
||||
admin_response = self.client.get(
|
||||
f"/api/logs/?workspace={self.workspace.id}",
|
||||
**self._auth_headers(self.admin),
|
||||
)
|
||||
|
||||
self.assertEqual(owner_response.status_code, 200)
|
||||
self.assertEqual(admin_response.status_code, 200)
|
||||
self.assertEqual(owner_response.data["items"][0]["section"], "tags")
|
||||
|
||||
def test_member_and_non_member_cannot_list_workspace_logs(self):
|
||||
self._create_tag(self.owner)
|
||||
|
||||
member_response = self.client.get(
|
||||
f"/api/logs/?workspace={self.workspace.id}",
|
||||
**self._auth_headers(self.member),
|
||||
)
|
||||
outsider_response = self.client.get(
|
||||
f"/api/logs/?workspace={self.workspace.id}",
|
||||
**self._auth_headers(self.outsider),
|
||||
)
|
||||
|
||||
self.assertEqual(member_response.status_code, 403)
|
||||
self.assertEqual(outsider_response.status_code, 403)
|
||||
|
||||
def test_jwt_authenticated_writes_capture_actor_and_workspace_metadata(self):
|
||||
response = self._create_tag(self.owner, name="JWT Tag")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
log_entry = LogEntry.objects.filter(content_type__app_label="tags").latest(
|
||||
"timestamp"
|
||||
)
|
||||
|
||||
self.assertEqual(log_entry.actor_id, self.owner.id)
|
||||
self.assertEqual(log_entry.additional_data["workspace_id"], str(self.workspace.id))
|
||||
self.assertEqual(log_entry.additional_data["section"], "tags")
|
||||
|
||||
def test_logs_support_section_filter_and_detail(self):
|
||||
tag_response = self._create_tag(self.owner, name="Filtered Tag")
|
||||
self.assertEqual(tag_response.status_code, 201)
|
||||
|
||||
list_response = self.client.get(
|
||||
f"/api/logs/?workspace={self.workspace.id}§ion=tags",
|
||||
**self._auth_headers(self.owner),
|
||||
)
|
||||
|
||||
self.assertEqual(list_response.status_code, 200)
|
||||
self.assertTrue(list_response.data["items"])
|
||||
log_id = list_response.data["items"][0]["id"]
|
||||
|
||||
detail_response = self.client.get(
|
||||
f"/api/logs/{log_id}/",
|
||||
**self._auth_headers(self.owner),
|
||||
)
|
||||
|
||||
self.assertEqual(detail_response.status_code, 200)
|
||||
self.assertEqual(detail_response.data["target"]["name"], "Filtered Tag")
|
||||
self.assertTrue(detail_response.data["changes"])
|
||||
|
||||
def test_soft_delete_and_actorless_background_logs_are_filtered(self):
|
||||
create_response = self._create_tag(self.owner, name="Delete Me")
|
||||
self.assertEqual(create_response.status_code, 201)
|
||||
tag_id = create_response.data["id"]
|
||||
|
||||
delete_response = self.client.delete(
|
||||
f"/api/tags/{tag_id}/",
|
||||
**self._auth_headers(self.owner),
|
||||
)
|
||||
self.assertEqual(delete_response.status_code, 204)
|
||||
|
||||
ReportExportJob.objects.create(
|
||||
requesting_user=self.owner,
|
||||
workspace=self.workspace,
|
||||
export_type=ReportExportJob.ExportType.PDF,
|
||||
filters={"workspace": str(self.workspace.id)},
|
||||
status=ReportExportJob.Status.PENDING,
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/logs/?workspace={self.workspace.id}&event=delete",
|
||||
**self._auth_headers(self.owner),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(
|
||||
any(
|
||||
item["event"] == "delete" and item["section"] == "tags"
|
||||
for item in response.data["items"]
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(item["section"] != "report_exports" for item in response.data["items"])
|
||||
)
|
||||
@@ -1,8 +1,4 @@
|
||||
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_deactivated,
|
||||
notify_workspace_membership_removed,
|
||||
@@ -16,8 +12,4 @@ __all__ = [
|
||||
"notify_workspace_membership_role_changed",
|
||||
"notify_workspace_membership_deactivated",
|
||||
"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}"
|
||||
|
||||
|
||||
def _project_action_url(project) -> str:
|
||||
return "/projects"
|
||||
|
||||
|
||||
def notify_workspace_membership_added(*, actor, recipient, workspace, role: str) -> None:
|
||||
if _should_skip(actor, recipient):
|
||||
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 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
|
||||
def mark_seen(cls, user_id: str, notif_id: str) -> dict | None:
|
||||
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 apps.notifications.services import store as services
|
||||
from apps.notifications.services import RedisNotificationStore
|
||||
from apps.notifications.tests.test_services import FakeRedis
|
||||
from apps.projects.models import Project, ProjectMembership
|
||||
from apps.notifications.tests.fakes import FakeRedis
|
||||
from apps.users.models import User
|
||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_redis(monkeypatch):
|
||||
redis = FakeRedis()
|
||||
monkeypatch.setattr(services, "redis_client", redis)
|
||||
return redis
|
||||
class WorkspaceMembershipNotificationTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.owner = cls._create_user(1)
|
||||
cls.member = cls._create_user(2)
|
||||
|
||||
@staticmethod
|
||||
def _create_user(index):
|
||||
return User.objects.create_user(
|
||||
mobile=f"091200000{index:02d}",
|
||||
password="secret123",
|
||||
first_name=f"User{index}",
|
||||
)
|
||||
|
||||
@pytest.fixture()
|
||||
def api_client():
|
||||
return APIClient()
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.fake_redis = FakeRedis()
|
||||
self.original_redis_client = services.redis_client
|
||||
services.redis_client = self.fake_redis
|
||||
|
||||
def tearDown(self):
|
||||
services.redis_client = self.original_redis_client
|
||||
|
||||
def _create_user(index: int) -> User:
|
||||
return User.objects.create_user(
|
||||
mobile=f"091200000{index:02d}",
|
||||
password="secret123",
|
||||
first_name=f"User{index}",
|
||||
)
|
||||
@staticmethod
|
||||
def _notifications_for(user):
|
||||
notifications, _ = RedisNotificationStore.list(str(user.id), paginate=False)
|
||||
return notifications
|
||||
|
||||
def test_workspace_create_notifies_initial_members_not_owner(self):
|
||||
self.client.force_authenticate(user=self.owner)
|
||||
|
||||
def _notifications_for(user):
|
||||
notifications, _ = RedisNotificationStore.list(
|
||||
str(user.id),
|
||||
paginate=False,
|
||||
)
|
||||
return notifications
|
||||
response = self.client.post(
|
||||
"/api/workspaces/",
|
||||
{
|
||||
"name": "Ops",
|
||||
"description": "Workspace",
|
||||
"members": [
|
||||
{
|
||||
"user_id": str(self.member.id),
|
||||
"role": WorkspaceMembership.Role.ADMIN,
|
||||
}
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(self._notifications_for(self.owner), [])
|
||||
member_notifications = self._notifications_for(self.member)
|
||||
self.assertEqual(len(member_notifications), 1)
|
||||
self.assertEqual(member_notifications[0]["type"], "workspace_membership_added")
|
||||
self.assertEqual(member_notifications[0]["meta"]["workspace_name"], "Ops")
|
||||
self.assertEqual(
|
||||
member_notifications[0]["meta"]["new_role"],
|
||||
WorkspaceMembership.Role.ADMIN,
|
||||
)
|
||||
|
||||
@pytest.fixture()
|
||||
def owner(db):
|
||||
return _create_user(1)
|
||||
def test_workspace_membership_crud_emits_all_expected_events(self):
|
||||
workspace = Workspace.objects.create(name="Design", description="", owner=self.owner)
|
||||
self.client.force_authenticate(user=self.owner)
|
||||
|
||||
create_response = self.client.post(
|
||||
"/api/workspace-memberships/",
|
||||
{
|
||||
"workspace": str(workspace.id),
|
||||
"user": str(self.member.id),
|
||||
"role": WorkspaceMembership.Role.MEMBER,
|
||||
"is_active": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(create_response.status_code, 201)
|
||||
|
||||
@pytest.fixture()
|
||||
def member(db):
|
||||
return _create_user(2)
|
||||
membership_id = create_response.data["id"]
|
||||
notifications = self._notifications_for(self.member)
|
||||
self.assertEqual(
|
||||
[item["type"] for item in notifications],
|
||||
["workspace_membership_added"],
|
||||
)
|
||||
|
||||
role_response = self.client.patch(
|
||||
f"/api/workspace-memberships/{membership_id}/",
|
||||
{"role": WorkspaceMembership.Role.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(role_response.status_code, 200)
|
||||
|
||||
@pytest.fixture()
|
||||
def another_member(db):
|
||||
return _create_user(3)
|
||||
deactivate_response = self.client.patch(
|
||||
f"/api/workspace-memberships/{membership_id}/",
|
||||
{"is_active": False},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(deactivate_response.status_code, 200)
|
||||
|
||||
remove_response = self.client.delete(
|
||||
f"/api/workspace-memberships/{membership_id}/"
|
||||
)
|
||||
self.assertEqual(remove_response.status_code, 204)
|
||||
|
||||
@pytest.fixture()
|
||||
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/",
|
||||
{
|
||||
"name": "Ops",
|
||||
"description": "Workspace",
|
||||
"members": [
|
||||
{"user_id": str(member.id), "role": WorkspaceMembership.Role.ADMIN}
|
||||
notifications = self._notifications_for(self.member)
|
||||
self.assertEqual(
|
||||
[item["type"] for item in notifications],
|
||||
[
|
||||
"workspace_membership_removed",
|
||||
"workspace_membership_deactivated",
|
||||
"workspace_membership_role_changed",
|
||||
"workspace_membership_added",
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
owner_notifications = _notifications_for(owner)
|
||||
member_notifications = _notifications_for(member)
|
||||
def test_workspace_membership_update_skips_self_notifications(self):
|
||||
workspace = Workspace.objects.create(name="Product", description="", owner=self.owner)
|
||||
owner_membership = WorkspaceMembership.objects.get(
|
||||
workspace=workspace,
|
||||
user=self.owner,
|
||||
is_deleted=False,
|
||||
)
|
||||
self.client.force_authenticate(user=self.owner)
|
||||
|
||||
assert owner_notifications == []
|
||||
assert len(member_notifications) == 1
|
||||
assert member_notifications[0]["type"] == "workspace_membership_added"
|
||||
assert member_notifications[0]["meta"]["workspace_name"] == "Ops"
|
||||
assert member_notifications[0]["meta"]["new_role"] == WorkspaceMembership.Role.ADMIN
|
||||
response = self.client.patch(
|
||||
f"/api/workspace-memberships/{owner_membership.id}/",
|
||||
{"role": WorkspaceMembership.Role.OWNER},
|
||||
format="json",
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_membership_crud_emits_add_role_change_deactivate_and_remove(
|
||||
fake_redis, api_client, owner, member
|
||||
):
|
||||
workspace = Workspace.objects.create(name="Design", description="", owner=owner)
|
||||
api_client.force_authenticate(user=owner)
|
||||
|
||||
create_response = api_client.post(
|
||||
"/api/workspace-memberships/",
|
||||
{
|
||||
"workspace": str(workspace.id),
|
||||
"user": str(member.id),
|
||||
"role": WorkspaceMembership.Role.MEMBER,
|
||||
"is_active": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
|
||||
membership_id = create_response.data["id"]
|
||||
notifications = _notifications_for(member)
|
||||
assert [item["type"] for item in notifications] == ["workspace_membership_added"]
|
||||
|
||||
role_response = api_client.patch(
|
||||
f"/api/workspace-memberships/{membership_id}/",
|
||||
{"role": WorkspaceMembership.Role.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
assert role_response.status_code == 200
|
||||
|
||||
deactivate_response = api_client.patch(
|
||||
f"/api/workspace-memberships/{membership_id}/",
|
||||
{"is_active": False},
|
||||
format="json",
|
||||
)
|
||||
assert deactivate_response.status_code == 200
|
||||
|
||||
remove_response = api_client.delete(f"/api/workspace-memberships/{membership_id}/")
|
||||
assert remove_response.status_code == 204
|
||||
|
||||
notifications = _notifications_for(member)
|
||||
assert [item["type"] for item in notifications] == [
|
||||
"workspace_membership_removed",
|
||||
"workspace_membership_deactivated",
|
||||
"workspace_membership_role_changed",
|
||||
"workspace_membership_added",
|
||||
]
|
||||
|
||||
|
||||
def test_workspace_membership_update_skips_self_notifications(
|
||||
fake_redis, api_client, owner
|
||||
):
|
||||
workspace = Workspace.objects.create(name="Product", description="", owner=owner)
|
||||
owner_membership = WorkspaceMembership.objects.get(
|
||||
workspace=workspace,
|
||||
user=owner,
|
||||
is_deleted=False,
|
||||
)
|
||||
api_client.force_authenticate(user=owner)
|
||||
|
||||
response = api_client.patch(
|
||||
f"/api/workspace-memberships/{owner_membership.id}/",
|
||||
{"role": WorkspaceMembership.Role.OWNER},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert _notifications_for(owner) == []
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(self._notifications_for(self.owner), [])
|
||||
|
||||
@@ -1,200 +1,78 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.notifications.services import store as services
|
||||
from apps.notifications.services import RedisNotificationStore
|
||||
from apps.notifications.tests.fakes import FakeRedis
|
||||
|
||||
|
||||
class FakePipeline:
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
self.operations = []
|
||||
class RedisNotificationStoreTests(TestCase):
|
||||
def setUp(self):
|
||||
self.fake_redis = FakeRedis()
|
||||
self.original_redis_client = services.redis_client
|
||||
services.redis_client = self.fake_redis
|
||||
|
||||
def __getattr__(self, name):
|
||||
def wrapper(*args, **kwargs):
|
||||
self.operations.append((name, args, kwargs))
|
||||
return self
|
||||
def tearDown(self):
|
||||
services.redis_client = self.original_redis_client
|
||||
|
||||
return wrapper
|
||||
def test_add_publishes_notification_and_unread_count(self):
|
||||
with self.settings(NOTIFICATIONS_ENABLED=True):
|
||||
notification = RedisNotificationStore.add(
|
||||
"user-1",
|
||||
{
|
||||
"title": "Build finished",
|
||||
"message": "Your deploy completed.",
|
||||
"level": "success",
|
||||
},
|
||||
)
|
||||
|
||||
def execute(self):
|
||||
results = []
|
||||
for name, args, kwargs in self.operations:
|
||||
results.append(getattr(self.client, name)(*args, **kwargs))
|
||||
self.operations.clear()
|
||||
return results
|
||||
self.assertEqual(notification["title"], "Build finished")
|
||||
self.assertEqual(notification["message"], "Your deploy completed.")
|
||||
self.assertEqual(notification["level"], "success")
|
||||
self.assertEqual(len(self.fake_redis.published), 2)
|
||||
|
||||
|
||||
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,
|
||||
channel, payload = self.fake_redis.published[0]
|
||||
self.assertEqual(
|
||||
channel,
|
||||
f"{settings.NOTIFICATION_REDIS_CHANNEL_PREFIX}:user-1",
|
||||
)
|
||||
if stop == -1:
|
||||
return [member for member, _ in items[start:]]
|
||||
return [member for member, _ in items[start : stop + 1]]
|
||||
self.assertEqual(payload["event"], "notification")
|
||||
self.assertEqual(payload["data"]["notification"]["id"], notification["id"])
|
||||
self.assertEqual(payload["data"]["unread_count"], 1)
|
||||
|
||||
def hget(self, key, field):
|
||||
return self.hashes[key].get(field)
|
||||
def test_mark_seen_and_mark_all_seen_publish_sync_events(self):
|
||||
with self.settings(NOTIFICATIONS_ENABLED=True):
|
||||
first = RedisNotificationStore.add("user-2", {"title": "First"})
|
||||
RedisNotificationStore.add("user-2", {"title": "Second"})
|
||||
self.fake_redis.published.clear()
|
||||
|
||||
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
|
||||
payload = RedisNotificationStore.mark_seen("user-2", first["id"])
|
||||
|
||||
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
|
||||
self.assertEqual(payload["notification_id"], first["id"])
|
||||
self.assertFalse(payload["deleted"])
|
||||
self.assertTrue(payload["notification"]["is_seen"])
|
||||
self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_seen")
|
||||
|
||||
def smembers(self, key):
|
||||
return set(self.sets[key])
|
||||
self.fake_redis.published.clear()
|
||||
updated = RedisNotificationStore.mark_all_seen("user-2")
|
||||
|
||||
def srem(self, key, member):
|
||||
if member in self.sets[key]:
|
||||
self.sets[key].remove(member)
|
||||
return 1
|
||||
return 0
|
||||
self.assertEqual(updated, 2)
|
||||
self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_mark_all_read")
|
||||
self.assertEqual(self.fake_redis.published[1][1]["event"], "unread_count")
|
||||
self.assertEqual(self.fake_redis.published[1][1]["data"]["unread_count"], 0)
|
||||
|
||||
def 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 test_list_returns_total_count_and_filtered_notifications(self):
|
||||
RedisNotificationStore.add("user-3", {"title": "General", "type": "general"})
|
||||
RedisNotificationStore.add("user-3", {"title": "Billing", "type": "billing"})
|
||||
RedisNotificationStore.add("user-3", {"title": "General 2", "type": "general"})
|
||||
|
||||
def zcard(self, key):
|
||||
return len(self.sorted_sets[key])
|
||||
notifications, total_count = RedisNotificationStore.list(
|
||||
"user-3",
|
||||
limit=1,
|
||||
offset=0,
|
||||
type_filter="general",
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
notification = RedisNotificationStore.add(
|
||||
"user-1",
|
||||
{
|
||||
"title": "Build finished",
|
||||
"message": "Your deploy completed.",
|
||||
"level": "success",
|
||||
},
|
||||
)
|
||||
|
||||
assert notification["title"] == "Build finished"
|
||||
assert notification["message"] == "Your deploy completed."
|
||||
assert notification["level"] == "success"
|
||||
assert len(fake_redis.published) == 2
|
||||
channel, payload = fake_redis.published[0]
|
||||
assert channel == f"{settings.NOTIFICATION_REDIS_CHANNEL_PREFIX}:user-1"
|
||||
assert payload["event"] == "notification"
|
||||
assert payload["data"]["notification"]["id"] == notification["id"]
|
||||
assert payload["data"]["unread_count"] == 1
|
||||
|
||||
|
||||
def test_mark_seen_and_mark_all_seen_publish_sync_events(fake_redis, settings):
|
||||
settings.NOTIFICATIONS_ENABLED = True
|
||||
first = RedisNotificationStore.add("user-2", {"title": "First"})
|
||||
second = RedisNotificationStore.add("user-2", {"title": "Second"})
|
||||
fake_redis.published.clear()
|
||||
|
||||
payload = RedisNotificationStore.mark_seen("user-2", first["id"])
|
||||
|
||||
assert payload["notification_id"] == first["id"]
|
||||
assert payload["deleted"] is False
|
||||
assert payload["notification"]["is_seen"] is True
|
||||
assert fake_redis.published[0][1]["event"] == "notification_seen"
|
||||
|
||||
fake_redis.published.clear()
|
||||
updated = RedisNotificationStore.mark_all_seen("user-2")
|
||||
|
||||
assert updated == 2
|
||||
assert fake_redis.published[0][1]["event"] == "notification_mark_all_read"
|
||||
assert fake_redis.published[1][1]["event"] == "unread_count"
|
||||
assert fake_redis.published[1][1]["data"]["unread_count"] == 0
|
||||
|
||||
|
||||
def test_list_returns_total_count_and_filtered_notifications(fake_redis):
|
||||
RedisNotificationStore.add("user-3", {"title": "General", "type": "general"})
|
||||
RedisNotificationStore.add("user-3", {"title": "Billing", "type": "billing"})
|
||||
RedisNotificationStore.add("user-3", {"title": "General 2", "type": "general"})
|
||||
|
||||
notifications, total_count = RedisNotificationStore.list(
|
||||
"user-3",
|
||||
limit=1,
|
||||
offset=0,
|
||||
type_filter="general",
|
||||
)
|
||||
|
||||
assert total_count == 2
|
||||
assert len(notifications) == 1
|
||||
assert notifications[0]["type"] == "general"
|
||||
self.assertEqual(total_count, 2)
|
||||
self.assertEqual(len(notifications), 1)
|
||||
self.assertEqual(notifications[0]["type"], "general")
|
||||
|
||||
20
apps/notifications/tests/test_tasks.py
Normal file
20
apps/notifications/tests/test_tasks.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.notifications.tasks import cleanup_redis_notifications
|
||||
|
||||
|
||||
class NotificationTaskTests(TestCase):
|
||||
@patch("apps.notifications.tasks.RedisNotificationStore.cleanup_expired")
|
||||
def test_cleanup_redis_notifications_uses_settings_retention_days(self, cleanup_expired):
|
||||
cleanup_expired.return_value = 7
|
||||
|
||||
removed = cleanup_redis_notifications()
|
||||
|
||||
self.assertEqual(removed, 7)
|
||||
cleanup_expired.assert_called_once_with(
|
||||
retention_days=settings.NOTIFICATION_RETENTION_DAYS
|
||||
)
|
||||
|
||||
@@ -1,166 +1,168 @@
|
||||
import json
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from apps.notifications.api import views
|
||||
from apps.notifications.services import store as services
|
||||
from apps.notifications.services import RedisNotificationStore
|
||||
from apps.notifications.tests.test_services import FakePubSub, FakeRedis
|
||||
from apps.notifications.tests.fakes import FakePubSub, FakeRedis
|
||||
from apps.users.models import User
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_redis(monkeypatch):
|
||||
redis = FakeRedis()
|
||||
monkeypatch.setattr(services, "redis_client", redis)
|
||||
return redis
|
||||
class NotificationViewTests(APITestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create_user(mobile="09121111111", password="secret123")
|
||||
cls.second_user = User.objects.create_user(
|
||||
mobile="09122222222",
|
||||
password="secret123",
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.fake_redis = FakeRedis()
|
||||
self.original_redis_client = services.redis_client
|
||||
services.redis_client = self.fake_redis
|
||||
|
||||
@pytest.fixture()
|
||||
def user(db):
|
||||
return User.objects.create_user(mobile="09121111111", password="secret123")
|
||||
def tearDown(self):
|
||||
services.redis_client = self.original_redis_client
|
||||
|
||||
@staticmethod
|
||||
def _read_sse_chunks(response, count):
|
||||
iterator = iter(response.streaming_content)
|
||||
chunks = []
|
||||
for _ in range(count):
|
||||
chunk = next(iterator)
|
||||
if isinstance(chunk, bytes):
|
||||
chunk = chunk.decode("utf-8")
|
||||
chunks.append(chunk)
|
||||
response.close()
|
||||
return chunks
|
||||
|
||||
@pytest.fixture()
|
||||
def second_user(db):
|
||||
return User.objects.create_user(mobile="09122222222", password="secret123")
|
||||
@staticmethod
|
||||
def _parse_sse_data(chunk):
|
||||
for line in chunk.splitlines():
|
||||
if line.startswith("data: "):
|
||||
return json.loads(line.removeprefix("data: "))
|
||||
raise AssertionError("SSE payload did not include data")
|
||||
|
||||
def test_stream_token_endpoint_returns_short_lived_token(self):
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def _read_sse_chunks(response, count):
|
||||
iterator = iter(response.streaming_content)
|
||||
chunks = []
|
||||
for _ in range(count):
|
||||
chunk = next(iterator)
|
||||
if isinstance(chunk, bytes):
|
||||
chunk = chunk.decode("utf-8")
|
||||
chunks.append(chunk)
|
||||
response.close()
|
||||
return chunks
|
||||
response = self.client.post("/api/notifications/stream-token/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data["token"])
|
||||
self.assertGreater(response.data["expires_in"], 0)
|
||||
|
||||
def _parse_sse_data(chunk: str) -> dict:
|
||||
for line in chunk.splitlines():
|
||||
if line.startswith("data: "):
|
||||
return json.loads(line.removeprefix("data: "))
|
||||
raise AssertionError("SSE payload did not include data")
|
||||
def test_stream_endpoint_rejects_missing_and_expired_token(self):
|
||||
missing = self.client.get("/api/notifications/stream/")
|
||||
self.assertEqual(missing.status_code, 401)
|
||||
|
||||
with override_settings(NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS=1):
|
||||
token = views._issue_stream_token_for_user(str(self.user.id))
|
||||
time.sleep(1.1)
|
||||
expired = self.client.get(f"/api/notifications/stream/?token={token}")
|
||||
|
||||
def test_stream_token_endpoint_returns_short_lived_token(user):
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
self.assertEqual(expired.status_code, 401)
|
||||
|
||||
response = client.post("/api/notifications/stream-token/")
|
||||
def test_stream_endpoint_sends_only_current_users_notifications(self):
|
||||
RedisNotificationStore.add(str(self.user.id), {"title": "For current user"})
|
||||
RedisNotificationStore.add(str(self.second_user.id), {"title": "For another user"})
|
||||
pubsub = FakePubSub()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["token"]
|
||||
assert response.data["expires_in"] > 0
|
||||
with patch.object(
|
||||
RedisNotificationStore,
|
||||
"get_pubsub",
|
||||
classmethod(lambda cls: pubsub),
|
||||
):
|
||||
token = views._issue_stream_token_for_user(str(self.user.id))
|
||||
response = self.client.get(
|
||||
f"/api/notifications/stream/?token={token}",
|
||||
HTTP_ACCEPT="text/event-stream",
|
||||
)
|
||||
retry_line, connected_chunk = self._read_sse_chunks(response, 2)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(retry_line.startswith("retry:"))
|
||||
connected = self._parse_sse_data(connected_chunk)
|
||||
self.assertEqual(connected["unread_count"], 1)
|
||||
self.assertEqual(
|
||||
[item["title"] for item in connected["notifications"]],
|
||||
["For current user"],
|
||||
)
|
||||
|
||||
def test_stream_endpoint_rejects_missing_and_expired_token(user, settings):
|
||||
client = APIClient()
|
||||
def test_stream_endpoint_emits_heartbeat(self):
|
||||
pubsub = FakePubSub()
|
||||
first_now = timezone.now()
|
||||
tick_values = iter(
|
||||
[
|
||||
first_now,
|
||||
first_now,
|
||||
first_now + timedelta(seconds=2),
|
||||
first_now + timedelta(seconds=2),
|
||||
first_now + timedelta(seconds=2),
|
||||
first_now + timedelta(seconds=2),
|
||||
]
|
||||
)
|
||||
last_tick = first_now + timedelta(seconds=2)
|
||||
|
||||
missing = client.get("/api/notifications/stream/")
|
||||
assert missing.status_code == 401
|
||||
def fake_now():
|
||||
return next(tick_values, last_tick)
|
||||
|
||||
settings.NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS = 1
|
||||
token = views._issue_stream_token_for_user(str(user.id))
|
||||
time.sleep(1.1)
|
||||
with override_settings(NOTIFICATION_SSE_HEARTBEAT_SECONDS=1):
|
||||
with patch.object(
|
||||
RedisNotificationStore,
|
||||
"get_pubsub",
|
||||
classmethod(lambda cls: pubsub),
|
||||
):
|
||||
with patch.object(views.timezone, "now", side_effect=fake_now):
|
||||
view = views.NotificationStreamView()
|
||||
stream = view._build_stream(str(self.user.id))
|
||||
chunks = [next(stream) for _ in range(4)]
|
||||
stream.close()
|
||||
|
||||
expired = client.get(f"/api/notifications/stream/?token={token}")
|
||||
assert expired.status_code == 401
|
||||
self.assertIn("event: ping", chunks[3])
|
||||
|
||||
def test_notification_list_and_seen_endpoints_work(self):
|
||||
notification = RedisNotificationStore.add(
|
||||
str(self.user.id),
|
||||
{"title": "Deploy succeeded", "type": "deploy"},
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_stream_endpoint_sends_only_current_users_notifications(
|
||||
fake_redis, user, second_user, monkeypatch
|
||||
):
|
||||
RedisNotificationStore.add(str(user.id), {"title": "For current user"})
|
||||
RedisNotificationStore.add(str(second_user.id), {"title": "For another user"})
|
||||
pubsub = FakePubSub()
|
||||
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub))
|
||||
token = views._issue_stream_token_for_user(str(user.id))
|
||||
list_response = self.client.get("/api/notifications/list/?type=deploy")
|
||||
self.assertEqual(list_response.status_code, 200)
|
||||
self.assertEqual(list_response.data["count"], 1)
|
||||
self.assertEqual(list_response.data["unread_count"], 1)
|
||||
self.assertEqual(
|
||||
list_response.data["notifications"][0]["title"],
|
||||
"Deploy succeeded",
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(
|
||||
f"/api/notifications/stream/?token={token}",
|
||||
HTTP_ACCEPT="text/event-stream",
|
||||
)
|
||||
retry_line, connected_chunk = _read_sse_chunks(response, 2)
|
||||
seen_response = self.client.post(
|
||||
"/api/notifications/seen/",
|
||||
{"id": notification["id"]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(seen_response.status_code, 200)
|
||||
self.assertTrue(seen_response.data["marked_read"])
|
||||
self.assertTrue(seen_response.data["notification"]["is_seen"])
|
||||
|
||||
assert response.status_code == 200
|
||||
assert retry_line.startswith("retry:")
|
||||
connected = _parse_sse_data(connected_chunk)
|
||||
assert connected["unread_count"] == 1
|
||||
assert [item["title"] for item in connected["notifications"]] == ["For current user"]
|
||||
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)
|
||||
|
||||
response = self.client.delete(f"/api/notifications/{notification['id']}/")
|
||||
|
||||
def test_stream_endpoint_emits_heartbeat(fake_redis, user, settings, monkeypatch):
|
||||
pubsub = FakePubSub()
|
||||
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub))
|
||||
settings.NOTIFICATION_SSE_HEARTBEAT_SECONDS = 1
|
||||
|
||||
first_now = timezone.now()
|
||||
tick_values = iter(
|
||||
[
|
||||
first_now,
|
||||
first_now,
|
||||
first_now + timedelta(seconds=2),
|
||||
first_now + timedelta(seconds=2),
|
||||
first_now + timedelta(seconds=2),
|
||||
first_now + timedelta(seconds=2),
|
||||
]
|
||||
)
|
||||
last_tick = first_now + timedelta(seconds=2)
|
||||
|
||||
def fake_now():
|
||||
return next(tick_values, last_tick)
|
||||
|
||||
monkeypatch.setattr(views.timezone, "now", fake_now)
|
||||
view = views.NotificationStreamView()
|
||||
stream = view._build_stream(str(user.id))
|
||||
|
||||
chunks = [next(stream) for _ in range(4)]
|
||||
stream.close()
|
||||
|
||||
assert "event: ping" in chunks[3]
|
||||
|
||||
|
||||
def test_notification_list_and_seen_endpoints_work(fake_redis, user):
|
||||
notification = RedisNotificationStore.add(
|
||||
str(user.id),
|
||||
{"title": "Deploy succeeded", "type": "deploy"},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
list_response = client.get("/api/notifications/list/?type=deploy")
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.data["count"] == 1
|
||||
assert list_response.data["unread_count"] == 1
|
||||
assert list_response.data["notifications"][0]["title"] == "Deploy succeeded"
|
||||
|
||||
seen_response = client.post("/api/notifications/seen/", {"id": notification["id"]}, format="json")
|
||||
assert seen_response.status_code == 200
|
||||
assert seen_response.data["marked_read"] is True
|
||||
assert seen_response.data["notification"]["is_seen"] is True
|
||||
|
||||
|
||||
def test_notification_delete_endpoint_removes_notification(fake_redis, user):
|
||||
notification = RedisNotificationStore.add(
|
||||
str(user.id),
|
||||
{"title": "Delete me", "type": "deploy"},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
response = client.delete(f"/api/notifications/{notification['id']}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["deleted"] is True
|
||||
assert response.data["notification_id"] == notification["id"]
|
||||
assert RedisNotificationStore.get(str(user.id), notification["id"]) is None
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data["deleted"])
|
||||
self.assertEqual(response.data["notification_id"], notification["id"])
|
||||
self.assertIsNone(RedisNotificationStore.get(str(self.user.id), notification["id"]))
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from core.admins.base import BaseAdmin
|
||||
from apps.projects.models import Project, ProjectMembership
|
||||
|
||||
|
||||
class ProjectMembershipInline(admin.TabularInline):
|
||||
model = ProjectMembership
|
||||
extra = 0
|
||||
autocomplete_fields = ("user",)
|
||||
from django.contrib import admin
|
||||
|
||||
from core.admins.base import BaseAdmin, SoftDeleteListFilter
|
||||
from apps.projects.models import Project
|
||||
|
||||
|
||||
@admin.register(Project)
|
||||
@@ -21,10 +15,11 @@ class ProjectAdmin(BaseAdmin):
|
||||
"created_at",
|
||||
)
|
||||
|
||||
list_filter = (
|
||||
"workspace",
|
||||
"is_archived",
|
||||
"is_deleted",
|
||||
list_filter = (
|
||||
SoftDeleteListFilter,
|
||||
"workspace",
|
||||
"is_archived",
|
||||
"is_deleted",
|
||||
)
|
||||
|
||||
search_fields = (
|
||||
@@ -33,36 +28,7 @@ class ProjectAdmin(BaseAdmin):
|
||||
"client__name",
|
||||
)
|
||||
|
||||
autocomplete_fields = (
|
||||
"workspace",
|
||||
"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",
|
||||
)
|
||||
autocomplete_fields = (
|
||||
"workspace",
|
||||
"client",
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from rest_framework import permissions
|
||||
|
||||
from apps.projects.models import ProjectMembership
|
||||
from apps.workspaces.services import (
|
||||
PROJECTS_EDIT,
|
||||
PROJECTS_VIEW,
|
||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||
has_project_capability,
|
||||
has_workspace_capability,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,9 +15,9 @@ def get_project_from_obj(obj):
|
||||
|
||||
|
||||
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 = "شما عضو این پروژه نیستید."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
@@ -27,13 +25,13 @@ class IsProjectMember(permissions.BasePermission):
|
||||
return False
|
||||
|
||||
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):
|
||||
"""
|
||||
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 = "فقط مدیران پروژه مجاز به انجام این عملیات هستند."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
@@ -41,19 +39,4 @@ class IsProjectManager(permissions.BasePermission):
|
||||
return False
|
||||
|
||||
project = get_project_from_obj(obj)
|
||||
return has_project_capability(request.user, project, 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,
|
||||
)
|
||||
return has_workspace_capability(request.user, project.workspace, PROJECTS_EDIT)
|
||||
|
||||
@@ -1,97 +1,115 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import serializers
|
||||
from core.serializers.base import BaseModelSerializer
|
||||
from apps.projects.models import (
|
||||
Project,
|
||||
ProjectMembership,
|
||||
)
|
||||
from core.serializers.mini import UserMiniSerializer
|
||||
from apps.projects.models import Project
|
||||
from apps.workspaces.models import PriceUnit
|
||||
|
||||
|
||||
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 ProjectSerializer(BaseModelSerializer):
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = BaseModelSerializer.Meta.fields + (
|
||||
"workspace",
|
||||
"name",
|
||||
"client",
|
||||
"description",
|
||||
"thumbnail",
|
||||
"is_archived",
|
||||
"color",
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
def to_representation(self, 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:
|
||||
representation['client'] = {
|
||||
'id': instance.client.id,
|
||||
'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
|
||||
|
||||
|
||||
class ProjectMemberInputSerializer(serializers.Serializer):
|
||||
user_id = serializers.UUIDField()
|
||||
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, default=ProjectMembership.Role.MEMBER)
|
||||
|
||||
|
||||
class ProjectSerializer(BaseModelSerializer):
|
||||
my_role = serializers.SerializerMethodField()
|
||||
members = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = BaseModelSerializer.Meta.fields + (
|
||||
"workspace",
|
||||
"name",
|
||||
"client",
|
||||
"description",
|
||||
"is_archived",
|
||||
"color",
|
||||
"my_role",
|
||||
"members",
|
||||
)
|
||||
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):
|
||||
representation = super().to_representation(instance)
|
||||
if instance.client:
|
||||
representation['client'] = {
|
||||
'id': instance.client.id,
|
||||
'name': instance.client.name
|
||||
}
|
||||
return representation
|
||||
|
||||
|
||||
class ProjectCreateSerializer(serializers.Serializer):
|
||||
workspace = serializers.UUIDField()
|
||||
name = serializers.CharField(max_length=255)
|
||||
client = serializers.UUIDField(required=False, allow_null=True)
|
||||
description = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="")
|
||||
members = ProjectMemberInputSerializer(many=True, required=False)
|
||||
|
||||
|
||||
class ProjectUpdateSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
client = serializers.UUIDField(required=False, allow_null=True)
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
color = serializers.CharField(max_length=7, required=False, allow_blank=True)
|
||||
is_archived = serializers.BooleanField(required=False)
|
||||
members = ProjectMemberInputSerializer(many=True, required=False)
|
||||
|
||||
class ProjectMembershipSerializer(BaseModelSerializer):
|
||||
user_details = UserMiniSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProjectMembership
|
||||
fields = BaseModelSerializer.Meta.fields + (
|
||||
"project",
|
||||
"user",
|
||||
"user_details",
|
||||
"role",
|
||||
"is_active",
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ProjectMembershipCreateSerializer(serializers.Serializer):
|
||||
project_id = serializers.UUIDField()
|
||||
user_id = serializers.UUIDField()
|
||||
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices)
|
||||
|
||||
|
||||
class ProjectMembershipUpdateSerializer(serializers.Serializer):
|
||||
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, required=False)
|
||||
is_active = serializers.BooleanField(required=False)
|
||||
class ProjectCreateSerializer(serializers.Serializer):
|
||||
workspace = serializers.UUIDField()
|
||||
name = serializers.CharField(max_length=255)
|
||||
client = serializers.UUIDField(required=False, allow_null=True)
|
||||
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="")
|
||||
|
||||
def validate_thumbnail(self, value):
|
||||
return validate_thumbnail(value)
|
||||
|
||||
|
||||
class ProjectUpdateSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
client = serializers.UUIDField(required=False, allow_null=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)
|
||||
is_archived = serializers.BooleanField(required=False)
|
||||
|
||||
def validate_thumbnail(self, value):
|
||||
return validate_thumbnail(value)
|
||||
|
||||
|
||||
class ProjectAccessQuerySerializer(serializers.Serializer):
|
||||
workspace = serializers.UUIDField()
|
||||
user = serializers.UUIDField()
|
||||
|
||||
|
||||
class ProjectAccessMutationSerializer(serializers.Serializer):
|
||||
workspace = serializers.UUIDField()
|
||||
user = serializers.UUIDField()
|
||||
project_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
allow_empty=False,
|
||||
)
|
||||
|
||||
|
||||
class ProjectAccessRateMutationSerializer(serializers.Serializer):
|
||||
workspace = serializers.UUIDField()
|
||||
user = serializers.UUIDField()
|
||||
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):
|
||||
code = value.upper()
|
||||
if not PriceUnit.objects.filter(code=code, is_deleted=False).exists():
|
||||
raise serializers.ValidationError("Selected price unit is invalid.")
|
||||
return code
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.projects.api.views import (
|
||||
ProjectViewSet,
|
||||
ProjectMembershipViewSet,
|
||||
)
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.projects.api.views import ProjectViewSet
|
||||
|
||||
app_name = "projects"
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"projects", ProjectViewSet, basename="project")
|
||||
router.register(r"memberships", ProjectMembershipViewSet, basename="membership")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
|
||||
@@ -1,47 +1,46 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
||||
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.clients.models import Client
|
||||
from apps.projects.models import (
|
||||
Project,
|
||||
ProjectMembership,
|
||||
)
|
||||
from apps.projects.models import Project
|
||||
from apps.projects.api.serializers import (
|
||||
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
|
||||
ProjectMembershipSerializer, ProjectMembershipCreateSerializer, ProjectMembershipUpdateSerializer,
|
||||
ProjectAccessMutationSerializer, ProjectAccessQuerySerializer,
|
||||
ProjectAccessRateMutationSerializer,
|
||||
)
|
||||
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 (
|
||||
create_project,
|
||||
update_project,
|
||||
create_project,
|
||||
update_project,
|
||||
toggle_project_archive
|
||||
)
|
||||
from apps.projects.services.memberships import add_project_member, update_project_member
|
||||
from apps.workspaces.services import (
|
||||
PROJECTS_ARCHIVE,
|
||||
PROJECTS_CREATE,
|
||||
PROJECTS_DELETE,
|
||||
PROJECTS_EDIT,
|
||||
PROJECT_MEMBERS_ADD,
|
||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||
PROJECT_MEMBERS_REMOVE,
|
||||
has_project_capability,
|
||||
can_delete_workspace_object,
|
||||
has_workspace_capability,
|
||||
)
|
||||
|
||||
@@ -58,13 +57,13 @@ class ProjectViewSet(ModelViewSet):
|
||||
ordering_fields = ["name", "created_at", "updated_at"]
|
||||
ordering = ["-updated_at", "-created_at"]
|
||||
|
||||
def get_permissions(self):
|
||||
"""
|
||||
Instantiates and returns the list of permissions that this view requires.
|
||||
- Managers can update, delete, or archive.
|
||||
- Members can retrieve/view.
|
||||
- Any authenticated user can list (filtered to their memberships) or attempt to create.
|
||||
"""
|
||||
def get_permissions(self):
|
||||
"""
|
||||
Instantiates and returns the list of permissions that this view requires.
|
||||
- Workspace-authorized users can update, delete, or archive.
|
||||
- Workspace members can retrieve/view.
|
||||
- Any authenticated user can list their workspace projects or attempt to create.
|
||||
"""
|
||||
if self.action in ["update", "partial_update", "destroy", "archive"]:
|
||||
permission_classes = [IsAuthenticated, IsProjectManager]
|
||||
elif self.action in ["retrieve"]:
|
||||
@@ -74,18 +73,26 @@ class ProjectViewSet(ModelViewSet):
|
||||
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Returns active projects where the current user is an active member.
|
||||
"""
|
||||
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
|
||||
return Project.objects.none()
|
||||
|
||||
return Project.objects.filter(
|
||||
memberships__user=self.request.user,
|
||||
memberships__is_active=True,
|
||||
is_deleted=False
|
||||
).distinct()
|
||||
def get_queryset(self):
|
||||
"""
|
||||
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:
|
||||
return Project.objects.none()
|
||||
|
||||
queryset = filter_projects_for_user(
|
||||
self.request.user,
|
||||
Project.objects.filter(is_deleted=False),
|
||||
)
|
||||
|
||||
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):
|
||||
"""
|
||||
@@ -98,14 +105,12 @@ class ProjectViewSet(ModelViewSet):
|
||||
return ProjectSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
Creates a new project using the project service layer.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
members_data = serializer.validated_data.pop("members", [])
|
||||
|
||||
"""
|
||||
Creates a new project using the project service layer.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
workspace = get_object_or_404(Workspace, id=serializer.validated_data["workspace"], is_deleted=False)
|
||||
if not has_workspace_capability(request.user, workspace, PROJECTS_CREATE):
|
||||
return Response(
|
||||
@@ -118,240 +123,170 @@ class ProjectViewSet(ModelViewSet):
|
||||
project = create_project(
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
name=serializer.validated_data["name"],
|
||||
client=client,
|
||||
description=serializer.validated_data.get("description", ""),
|
||||
color=serializer.validated_data.get("color", "")
|
||||
)
|
||||
|
||||
for member in members_data:
|
||||
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)
|
||||
name=serializer.validated_data["name"],
|
||||
client=client,
|
||||
description=serializer.validated_data.get("description", ""),
|
||||
color=serializer.validated_data.get("color", ""),
|
||||
thumbnail=serializer.validated_data.get("thumbnail"),
|
||||
)
|
||||
|
||||
output_serializer = ProjectSerializer(project, context={"request": request})
|
||||
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""
|
||||
Updates an existing project using the project service layer.
|
||||
"""
|
||||
partial = kwargs.pop("partial", False)
|
||||
project = self.get_object()
|
||||
"""
|
||||
Updates an existing project using the project service layer.
|
||||
"""
|
||||
partial = kwargs.pop("partial", False)
|
||||
project = self.get_object()
|
||||
|
||||
serializer = self.get_serializer(data=request.data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
members_data = serializer.validated_data.pop("members", None)
|
||||
|
||||
updated_project = update_project(
|
||||
project=project,
|
||||
**serializer.validated_data
|
||||
)
|
||||
|
||||
# Full sync of project members if array is provided
|
||||
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)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""
|
||||
Soft deletes a project.
|
||||
"""
|
||||
project = self.get_object()
|
||||
project.is_deleted = True
|
||||
project.save(update_fields=["is_deleted", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def archive(self, request, pk=None):
|
||||
"""
|
||||
Custom endpoint to toggle the archive status of a project.
|
||||
"""
|
||||
project = self.get_object()
|
||||
updated_project = toggle_project_archive(project)
|
||||
|
||||
output_serializer = ProjectSerializer(updated_project)
|
||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class BaseProjectNestedViewSet(ModelViewSet):
|
||||
"""
|
||||
Base ViewSet for nested project models to share common permission and queryset logic.
|
||||
"""
|
||||
pagination_class = CustomLimitOffsetPagination
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter]
|
||||
ordering = ["-updated_at", "-created_at"]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ["create", "update", "partial_update", "destroy"]:
|
||||
permission_classes = [IsAuthenticated, IsProjectManager]
|
||||
else:
|
||||
permission_classes = [IsAuthenticated, IsProjectMember]
|
||||
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,
|
||||
user_id=serializer.validated_data["user_id"],
|
||||
role=serializer.validated_data["role"]
|
||||
)
|
||||
notify_project_membership_added(
|
||||
actor=request.user,
|
||||
recipient=membership.user,
|
||||
project=project,
|
||||
role=membership.role,
|
||||
)
|
||||
return Response(ProjectMembershipSerializer(membership).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
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 = self.get_serializer(data=request.data, partial=partial)
|
||||
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,
|
||||
updated_project = update_project(
|
||||
project=project,
|
||||
role=role,
|
||||
**serializer.validated_data
|
||||
)
|
||||
|
||||
output_serializer = ProjectSerializer(updated_project, context={"request": request})
|
||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""
|
||||
Soft deletes a project.
|
||||
"""
|
||||
project = self.get_object()
|
||||
if not can_delete_workspace_object(request.user, project, PROJECTS_DELETE):
|
||||
return Response(
|
||||
{"detail": "You do not have permission to delete this project."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
project.is_deleted = True
|
||||
project.save(update_fields=["is_deleted", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def archive(self, request, pk=None):
|
||||
"""
|
||||
Custom endpoint to toggle the archive status of a project.
|
||||
"""
|
||||
project = self.get_object()
|
||||
updated_project = toggle_project_archive(project)
|
||||
|
||||
output_serializer = ProjectSerializer(updated_project, context={"request": request})
|
||||
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)
|
||||
|
||||
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"]))
|
||||
|
||||
return Response(
|
||||
{
|
||||
"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:
|
||||
upsert_project_user_rate(
|
||||
project=project,
|
||||
user=membership.user,
|
||||
hourly_rate=serializer.validated_data["hourly_rate"],
|
||||
currency=serializer.validated_data.get("currency", "USD"),
|
||||
)
|
||||
|
||||
workspace_rate = (
|
||||
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,
|
||||
has_access=True,
|
||||
workspace_rate=workspace_rate,
|
||||
project_rate=project_rate,
|
||||
)
|
||||
return Response({"removed": removed, "item": item}, status=status.HTTP_200_OK)
|
||||
|
||||
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/'),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,8 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
|
||||
from apps.logs.services import build_workspace_log_metadata
|
||||
from apps.logs.services.constants import SECTION_PROJECTS
|
||||
from core.models.base import BaseModel
|
||||
from apps.workspaces.models import Workspace
|
||||
|
||||
@@ -26,6 +28,8 @@ class Project(BaseModel):
|
||||
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
thumbnail = models.ImageField(upload_to="profile/projects/", blank=True, null=True)
|
||||
|
||||
is_archived = models.BooleanField(default=False)
|
||||
|
||||
color = models.CharField(max_length=7, blank=True)
|
||||
@@ -35,6 +39,7 @@ class Project(BaseModel):
|
||||
ordering = ("-updated_at", "-created_at")
|
||||
indexes = [
|
||||
models.Index(fields=["workspace"], name="project_workspace_idx"),
|
||||
models.Index(fields=["workspace", "is_archived", "updated_at"], name="project_ws_arch_upd_idx"),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
@@ -47,50 +52,14 @@ class Project(BaseModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
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_PROJECTS,
|
||||
workspace_id=self.workspace_id,
|
||||
target_id=self.id,
|
||||
target_label=self.name,
|
||||
extra={"client_id": str(self.client_id) if self.client_id else None},
|
||||
)
|
||||
|
||||
|
||||
class ProjectRate(BaseModel):
|
||||
@@ -163,3 +132,31 @@ class ProjectUserRate(BaseModel):
|
||||
models.Index(fields=["project"], name="pur_project_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
|
||||
@@ -1,18 +1,17 @@
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.exceptions import ValidationError, PermissionDenied
|
||||
|
||||
from apps.clients.models import Client
|
||||
from apps.projects.models import Project, ProjectMembership
|
||||
from apps.workspaces.models import WorkspaceMembership
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.exceptions import ValidationError, PermissionDenied
|
||||
|
||||
from apps.clients.models import Client
|
||||
from apps.projects.models import Project
|
||||
from apps.workspaces.models import WorkspaceMembership
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_project(user, workspace, name, client=None, description="", color=""):
|
||||
"""
|
||||
Creates a new project and automatically assigns the creator
|
||||
as an active MANAGER of that project.
|
||||
"""
|
||||
@transaction.atomic
|
||||
def create_project(user, workspace, name, client=None, description="", color="", thumbnail=None):
|
||||
"""
|
||||
Creates a new workspace-shared project.
|
||||
"""
|
||||
workspace_member = WorkspaceMembership.objects.filter(
|
||||
workspace=workspace,
|
||||
user=user,
|
||||
@@ -26,25 +25,21 @@ def create_project(user, workspace, name, client=None, description="", color="")
|
||||
if Project.objects.filter(workspace=workspace, name=name, is_deleted=False).exists():
|
||||
raise ValidationError({"name": "A project with this name already exists in the workspace."})
|
||||
|
||||
project = Project.objects.create(
|
||||
workspace=workspace,
|
||||
name=name,
|
||||
client=client,
|
||||
description=description,
|
||||
color=color
|
||||
)
|
||||
project = Project.objects.create(
|
||||
workspace=workspace,
|
||||
name=name,
|
||||
client=client,
|
||||
description=description,
|
||||
thumbnail=thumbnail,
|
||||
color=color,
|
||||
created_by=user,
|
||||
updated_by=user,
|
||||
)
|
||||
|
||||
ProjectMembership.objects.create(
|
||||
project=project,
|
||||
user=user,
|
||||
role=ProjectMembership.Role.MANAGER,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
return project
|
||||
return project
|
||||
|
||||
|
||||
def update_project(project, **kwargs):
|
||||
def update_project(project, **kwargs):
|
||||
"""
|
||||
Updates specific fields of an existing project.
|
||||
"""
|
||||
@@ -55,20 +50,32 @@ def update_project(project, **kwargs):
|
||||
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."})
|
||||
|
||||
client_id = kwargs.pop("client")
|
||||
client = get_object_or_404(Client, id=client_id, is_deleted=False) if client_id else None
|
||||
kwargs["client"] = client
|
||||
if "client" in kwargs:
|
||||
client_id = kwargs.pop("client")
|
||||
client = get_object_or_404(Client, id=client_id, is_deleted=False) if client_id else None
|
||||
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():
|
||||
if hasattr(project, field) and getattr(project, field) != value:
|
||||
setattr(project, field, value)
|
||||
update_fields.append(field)
|
||||
for field, value in kwargs.items():
|
||||
if hasattr(project, field) and getattr(project, field) != value:
|
||||
setattr(project, field, value)
|
||||
update_fields.append(field)
|
||||
|
||||
if update_fields:
|
||||
update_fields.append("updated_at")
|
||||
project.save(update_fields=update_fields)
|
||||
|
||||
return project
|
||||
update_fields.append("updated_at")
|
||||
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
|
||||
|
||||
|
||||
def toggle_project_archive(project) -> 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,
|
||||
ReportExportJobViewSet,
|
||||
ReportTableView,
|
||||
ReportUserSummaryView,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
@@ -15,6 +16,6 @@ urlpatterns = [
|
||||
path("chart/", ReportChartView.as_view(), name="report-chart"),
|
||||
path("table/", ReportTableView.as_view(), name="report-table"),
|
||||
path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"),
|
||||
path("user-summary/", ReportUserSummaryView.as_view(), name="report-user-summary"),
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
|
||||
@@ -20,9 +20,14 @@ from apps.reports.services import (
|
||||
build_chart_report,
|
||||
build_day_details_report,
|
||||
build_table_report,
|
||||
build_user_summary_report,
|
||||
load_report_filters,
|
||||
)
|
||||
from apps.reports.tasks import generate_report_export_task
|
||||
from core.services.cache import CACHE_NAMESPACE_REPORTS, get_or_set_cache_payload
|
||||
|
||||
|
||||
REPORT_CACHE_TTL_SECONDS = 90
|
||||
|
||||
|
||||
class ReportChartView(APIView):
|
||||
@@ -30,7 +35,17 @@ class ReportChartView(APIView):
|
||||
|
||||
@extend_schema(responses=dict)
|
||||
def get(self, request):
|
||||
return Response(build_chart_report(request.user, request.query_params))
|
||||
workspace_id = request.query_params.get("workspace")
|
||||
payload = get_or_set_cache_payload(
|
||||
CACHE_NAMESPACE_REPORTS,
|
||||
ttl_seconds=REPORT_CACHE_TTL_SECONDS,
|
||||
builder=lambda: build_chart_report(request.user, request.query_params),
|
||||
resource="chart",
|
||||
user_id=request.user.id,
|
||||
workspace_id=workspace_id,
|
||||
params=request.query_params,
|
||||
)
|
||||
return Response(payload)
|
||||
|
||||
|
||||
class ReportTableView(APIView):
|
||||
@@ -38,7 +53,17 @@ class ReportTableView(APIView):
|
||||
|
||||
@extend_schema(responses=dict)
|
||||
def get(self, request):
|
||||
return Response(build_table_report(request.user, request.query_params))
|
||||
workspace_id = request.query_params.get("workspace")
|
||||
payload = get_or_set_cache_payload(
|
||||
CACHE_NAMESPACE_REPORTS,
|
||||
ttl_seconds=REPORT_CACHE_TTL_SECONDS,
|
||||
builder=lambda: build_table_report(request.user, request.query_params),
|
||||
resource="table",
|
||||
user_id=request.user.id,
|
||||
workspace_id=workspace_id,
|
||||
params=request.query_params,
|
||||
)
|
||||
return Response(payload)
|
||||
|
||||
|
||||
class ReportDayDetailsView(APIView):
|
||||
@@ -46,7 +71,35 @@ class ReportDayDetailsView(APIView):
|
||||
|
||||
@extend_schema(responses=dict)
|
||||
def get(self, request):
|
||||
return Response(build_day_details_report(request.user, request.query_params))
|
||||
workspace_id = request.query_params.get("workspace")
|
||||
payload = get_or_set_cache_payload(
|
||||
CACHE_NAMESPACE_REPORTS,
|
||||
ttl_seconds=REPORT_CACHE_TTL_SECONDS,
|
||||
builder=lambda: build_day_details_report(request.user, request.query_params),
|
||||
resource="day-details",
|
||||
user_id=request.user.id,
|
||||
workspace_id=workspace_id,
|
||||
params=request.query_params,
|
||||
)
|
||||
return Response(payload)
|
||||
|
||||
|
||||
class 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(
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-26 19:23
|
||||
|
||||
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 = [
|
||||
('workspaces', '0005_remove_priceunit_priceunit_id_idx_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReportExportJob',
|
||||
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)),
|
||||
('export_type', models.CharField(choices=[('excel', 'Excel'), ('pdf', 'PDF')], max_length=16)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed'), ('expired', 'Expired')], default='pending', max_length=16)),
|
||||
('filters', models.JSONField(default=dict)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to='reports/exports/')),
|
||||
('file_name', models.CharField(blank=True, max_length=255)),
|
||||
('error_message', models.TextField(blank=True)),
|
||||
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=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)),
|
||||
('requesting_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_export_jobs', 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)),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_export_jobs', to='workspaces.workspace')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'report_export_job',
|
||||
'ordering': ('-created_at',),
|
||||
'indexes': [models.Index(fields=['requesting_user'], name='report_export_user_idx'), models.Index(fields=['workspace'], name='report_export_workspace_idx'), models.Index(fields=['status'], name='report_export_status_idx'), models.Index(fields=['expires_at'], name='report_export_expires_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
# Generated by Django 5.2.12 on 2026-04-26 19:23
|
||||
|
||||
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 = [
|
||||
('workspaces', '0005_remove_priceunit_priceunit_id_idx_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReportExportJob',
|
||||
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)),
|
||||
('export_type', models.CharField(choices=[('excel', 'Excel'), ('pdf', 'PDF')], max_length=16)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed'), ('expired', 'Expired')], default='pending', max_length=16)),
|
||||
('filters', models.JSONField(default=dict)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to='reports/exports/')),
|
||||
('file_name', models.CharField(blank=True, max_length=255)),
|
||||
('error_message', models.TextField(blank=True)),
|
||||
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=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)),
|
||||
('requesting_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_export_jobs', 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)),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_export_jobs', to='workspaces.workspace')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'report_export_job',
|
||||
'ordering': ('-created_at',),
|
||||
'indexes': [models.Index(fields=['requesting_user'], name='report_export_user_idx'), models.Index(fields=['workspace'], name='report_export_workspace_idx'), models.Index(fields=['status'], name='report_export_status_idx'), models.Index(fields=['expires_at'], name='report_export_expires_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -2,6 +2,8 @@ from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.logs.services import build_workspace_log_metadata
|
||||
from apps.logs.services.constants import SECTION_REPORT_EXPORTS
|
||||
from core.models.base import BaseModel
|
||||
|
||||
|
||||
@@ -81,3 +83,14 @@ class ReportExportJob(BaseModel):
|
||||
self.status = self.Status.EXPIRED
|
||||
self.save(update_fields=["status", "updated_at"])
|
||||
|
||||
def get_additional_data(self):
|
||||
return build_workspace_log_metadata(
|
||||
section=SECTION_REPORT_EXPORTS,
|
||||
workspace_id=self.workspace_id,
|
||||
target_id=self.id,
|
||||
target_label=f"{self.export_type.upper()} export",
|
||||
extra={
|
||||
"requesting_user_id": str(self.requesting_user_id),
|
||||
"status": self.status,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from apps.reports.services.aggregation import (
|
||||
build_chart_report,
|
||||
build_day_details_report,
|
||||
build_table_report,
|
||||
build_user_summary_report,
|
||||
build_user_scoped_table_reports,
|
||||
load_report_filters,
|
||||
)
|
||||
@@ -10,6 +11,7 @@ __all__ = [
|
||||
"load_report_filters",
|
||||
"build_chart_report",
|
||||
"build_table_report",
|
||||
"build_user_summary_report",
|
||||
"build_user_scoped_table_reports",
|
||||
"build_day_details_report",
|
||||
]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Iterable
|
||||
from decimal import ROUND_DOWN, Decimal
|
||||
|
||||
import jdatetime
|
||||
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.projects.models import Project
|
||||
from apps.projects.services.access import user_has_project_access
|
||||
from apps.tags.models import Tag
|
||||
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
|
||||
|
||||
User = get_user_model()
|
||||
@@ -38,6 +39,25 @@ ALLOWED_PERIODS = {
|
||||
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:
|
||||
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):
|
||||
if not entry.is_billable or not entry.hourly_rate:
|
||||
return
|
||||
@@ -135,7 +523,13 @@ class ReportFilterSerializer(serializers.Serializer):
|
||||
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()
|
||||
if language == "fa":
|
||||
today_jalali = jdatetime.date.fromgregorian(date=today)
|
||||
@@ -203,7 +597,11 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
|
||||
"user": raw_data.get("user"),
|
||||
"client": raw_data.get("client"),
|
||||
"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"),
|
||||
}
|
||||
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.")
|
||||
if project_id and not Project.objects.filter(id=project_id, workspace=workspace).exists():
|
||||
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:
|
||||
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):
|
||||
@@ -263,8 +665,15 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
|
||||
|
||||
|
||||
def _base_queryset(filters: ReportFilters) -> QuerySet[TimeEntry]:
|
||||
start_dt = timezone.make_aware(datetime.combine(filters.from_date, time.min), timezone.get_current_timezone())
|
||||
end_dt = timezone.make_aware(datetime.combine(filters.to_date + timedelta(days=1), time.min), timezone.get_current_timezone())
|
||||
current_timezone = 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 = (
|
||||
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}:
|
||||
bucket_date = local_dt.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)
|
||||
return bucket_date.strftime("%Y-%m"), bucket_date
|
||||
|
||||
@@ -378,31 +784,57 @@ def build_chart_report(actor, raw_filters) -> dict:
|
||||
filters = load_report_filters(actor, raw_filters)
|
||||
entries = list(_base_queryset(filters))
|
||||
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:
|
||||
grouped_entries[str(entry.user_id)].append(entry)
|
||||
else:
|
||||
grouped_entries[filters.user_id] = entries
|
||||
|
||||
for entry in entries:
|
||||
local_start = _localize_datetime(entry.start_time)
|
||||
bucket_id, bucket_date = _bucket_key(filters, local_start)
|
||||
bucket = buckets.setdefault(
|
||||
bucket_id,
|
||||
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)
|
||||
bucket_id, bucket_date = _bucket_key(filters, local_start)
|
||||
bucket = buckets.setdefault(
|
||||
bucket_id,
|
||||
{
|
||||
"bucket_key": bucket_id,
|
||||
"bucket_label": _bucket_label(filters, bucket_date),
|
||||
"total_seconds": 0,
|
||||
"total_duration": "00:00:00",
|
||||
},
|
||||
)
|
||||
bucket["total_seconds"] += get_entry_duration_seconds(entry)
|
||||
|
||||
serialized_buckets = []
|
||||
for bucket in sorted(buckets.values(), key=lambda item: item["bucket_key"]):
|
||||
bucket["total_duration"] = _format_duration_seconds(bucket["total_seconds"])
|
||||
serialized_buckets.append(bucket)
|
||||
|
||||
user = series_entries[0].user
|
||||
serialized_series.append(
|
||||
{
|
||||
"bucket_key": bucket_id,
|
||||
"bucket_label": _bucket_label(filters, bucket_date),
|
||||
"total_seconds": 0,
|
||||
"total_duration": "00:00:00",
|
||||
},
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"name": _user_display(user),
|
||||
"mobile": user.mobile,
|
||||
},
|
||||
"buckets": serialized_buckets,
|
||||
}
|
||||
)
|
||||
bucket["total_seconds"] += get_entry_duration_seconds(entry)
|
||||
|
||||
serialized_buckets = []
|
||||
for bucket in sorted(buckets.values(), key=lambda item: item["bucket_key"]):
|
||||
bucket["total_duration"] = _format_duration_seconds(bucket["total_seconds"])
|
||||
serialized_buckets.append(bucket)
|
||||
|
||||
return {
|
||||
"scope": _scope_payload(filters),
|
||||
"summary": summary,
|
||||
"buckets": serialized_buckets,
|
||||
"series": serialized_series,
|
||||
}
|
||||
|
||||
|
||||
@@ -418,7 +850,15 @@ def _scope_payload(filters: ReportFilters) -> dict:
|
||||
}
|
||||
|
||||
return {
|
||||
"workspace": {"id": str(filters.workspace.id), "name": filters.workspace.name},
|
||||
"workspace": {
|
||||
"id": str(filters.workspace.id),
|
||||
"name": filters.workspace.name,
|
||||
"thumbnail_path": (
|
||||
filters.workspace.thumbnail.path
|
||||
if getattr(filters.workspace, "thumbnail", None)
|
||||
else None
|
||||
),
|
||||
},
|
||||
"period": filters.period,
|
||||
"from_date": filters.from_date.isoformat(),
|
||||
"to_date": filters.to_date.isoformat(),
|
||||
@@ -432,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)
|
||||
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),
|
||||
"summary": summary,
|
||||
"days": _group_daily(entries),
|
||||
"clients": _build_breakdown(entries, "clients"),
|
||||
"projects": _build_breakdown(entries, "projects"),
|
||||
"tags": _build_breakdown(entries, "tags"),
|
||||
"days": _group_daily(entries, include_latest_rate=include_latest_rate),
|
||||
"clients": client_rows,
|
||||
"projects": project_rows,
|
||||
"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] = {}
|
||||
for entry in entries:
|
||||
local_start = _localize_datetime(entry.start_time)
|
||||
@@ -457,6 +924,9 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
|
||||
"non_billable_seconds": 0,
|
||||
"total_seconds": 0,
|
||||
"income": _money_map(),
|
||||
"latest_rate_amount": None,
|
||||
"latest_rate_currency": None,
|
||||
"latest_rate_timestamp": None,
|
||||
},
|
||||
)
|
||||
duration_seconds = get_entry_duration_seconds(entry)
|
||||
@@ -466,6 +936,18 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
|
||||
else:
|
||||
day_bucket["non_billable_seconds"] += duration_seconds
|
||||
_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 = []
|
||||
for day_key in sorted(by_day.keys()):
|
||||
@@ -479,70 +961,41 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
|
||||
"billable_duration": _format_duration_seconds(bucket["billable_seconds"]),
|
||||
"non_billable_duration": _format_duration_seconds(bucket["non_billable_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"]),
|
||||
}
|
||||
)
|
||||
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] = {}
|
||||
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)
|
||||
bucket["total_seconds"] += duration_seconds
|
||||
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(
|
||||
item_id,
|
||||
{
|
||||
"id": item_id,
|
||||
"name": item_name,
|
||||
"billable_seconds": 0,
|
||||
"non_billable_seconds": 0,
|
||||
"total_seconds": 0,
|
||||
"income": _money_map(),
|
||||
},
|
||||
)
|
||||
duration_seconds = get_entry_duration_seconds(entry)
|
||||
bucket["total_seconds"] += duration_seconds
|
||||
if entry.is_billable:
|
||||
bucket["billable_seconds"] += duration_seconds
|
||||
else:
|
||||
bucket["non_billable_seconds"] += duration_seconds
|
||||
_add_income(bucket["income"], entry)
|
||||
for item_id, item_name in _breakdown_targets(entry, kind, language):
|
||||
bucket = data.setdefault(
|
||||
item_id,
|
||||
{
|
||||
"id": item_id,
|
||||
"name": item_name,
|
||||
"billable_seconds": 0,
|
||||
"non_billable_seconds": 0,
|
||||
"total_seconds": 0,
|
||||
"income": _money_map(),
|
||||
},
|
||||
)
|
||||
bucket["total_seconds"] += duration_seconds
|
||||
if entry.is_billable:
|
||||
bucket["billable_seconds"] += duration_seconds
|
||||
else:
|
||||
bucket["non_billable_seconds"] += duration_seconds
|
||||
_add_income(bucket["income"], entry)
|
||||
|
||||
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(
|
||||
{
|
||||
"id": bucket["id"],
|
||||
@@ -562,7 +1015,47 @@ def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]:
|
||||
def build_table_report(actor, raw_filters) -> dict:
|
||||
filters = load_report_filters(actor, raw_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]:
|
||||
@@ -583,7 +1076,20 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
||||
reports: list[dict] = []
|
||||
for user_id, user_entries in sorted_groups:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
import jdatetime
|
||||
from arabic_reshaper import reshape
|
||||
@@ -23,18 +24,39 @@ TRANSLATIONS = {
|
||||
"en": {
|
||||
"report_title": "Workspace Report",
|
||||
"overall_sheet": "Overall Report",
|
||||
"users_summary_sheet": "Users Summary",
|
||||
"workspace": "Workspace",
|
||||
"period": "Period",
|
||||
"from_date": "From date",
|
||||
"to_date": "To date",
|
||||
"user": "User",
|
||||
"mobile": "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",
|
||||
"hourly_rate": "Hourly rate",
|
||||
"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",
|
||||
"clients": "Clients",
|
||||
"projects": "Projects",
|
||||
@@ -43,22 +65,46 @@ TRANSLATIONS = {
|
||||
"name": "Name",
|
||||
"total": "Total",
|
||||
"no_data": "No data",
|
||||
"uncategorized_client": "No client",
|
||||
"uncategorized_project": "No project",
|
||||
"uncategorized_tag": "No tag",
|
||||
},
|
||||
"fa": {
|
||||
"report_title": "گزارش فضای کاری",
|
||||
"overall_sheet": "گزارش کلی",
|
||||
"users_summary_sheet": "خلاصه کاربران",
|
||||
"workspace": "فضای کاری",
|
||||
"period": "بازه",
|
||||
"from_date": "از تاریخ",
|
||||
"to_date": "تا تاریخ",
|
||||
"user": "کاربر",
|
||||
"mobile": "موبایل",
|
||||
"all_users": "همه کاربران",
|
||||
"generated_at": "تاریخ تولید",
|
||||
"summary": "خلاصه",
|
||||
"total_hours": "کل ساعات",
|
||||
"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": "خلاصه روزانه",
|
||||
"clients": "مشتریان",
|
||||
"projects": "پروژهها",
|
||||
@@ -67,6 +113,9 @@ TRANSLATIONS = {
|
||||
"name": "نام",
|
||||
"total": "جمع",
|
||||
"no_data": "بدون داده",
|
||||
"uncategorized_client": "بدون مشتری",
|
||||
"uncategorized_project": "بدون پروژه",
|
||||
"uncategorized_tag": "بدون تگ",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -89,6 +138,18 @@ PERIOD_LABELS = {
|
||||
},
|
||||
}
|
||||
|
||||
CURRENCY_LABELS = {
|
||||
"USD": {"en": "USD", "fa": "دلار آمریکا"},
|
||||
"EUR": {"en": "EUR", "fa": "یورو"},
|
||||
"GBP": {"en": "GBP", "fa": "پوند"},
|
||||
"IRR": {"en": "IRR", "fa": "ریال"},
|
||||
"IRT": {"en": "IRT", "fa": "تومان"},
|
||||
"AED": {"en": "AED", "fa": "درهم"},
|
||||
"TRY": {"en": "TRY", "fa": "لیر"},
|
||||
}
|
||||
|
||||
DECIMAL_TRIM_CURRENCIES = {"IRR", "IRT"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExportLocale:
|
||||
@@ -122,14 +183,55 @@ class ExportLocale:
|
||||
def format_duration(self, value: str, *, ascii_digits: bool = False) -> str:
|
||||
return self.format_number(value, ascii_digits=ascii_digits)
|
||||
|
||||
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()
|
||||
if not raw:
|
||||
return raw
|
||||
try:
|
||||
decimal_value = Decimal(raw)
|
||||
except InvalidOperation:
|
||||
return self.format_number(raw, ascii_digits=ascii_digits)
|
||||
|
||||
sign = "-" if decimal_value < 0 else ""
|
||||
unsigned = abs(decimal_value)
|
||||
normalized = format(unsigned, "f")
|
||||
integer_part, _, fractional_part = normalized.partition(".")
|
||||
grouped_integer = f"{int(integer_part):,}"
|
||||
formatted = f"{sign}{grouped_integer}"
|
||||
if fractional_part:
|
||||
trimmed_fraction = (
|
||||
""
|
||||
if str(currency or "").upper() in DECIMAL_TRIM_CURRENCIES
|
||||
else fractional_part.rstrip("0")
|
||||
)
|
||||
if trimmed_fraction:
|
||||
formatted = f"{formatted}.{trimmed_fraction}"
|
||||
return self.format_number(formatted, ascii_digits=ascii_digits)
|
||||
|
||||
def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str:
|
||||
if not income_totals:
|
||||
return "-"
|
||||
return self.format_number("0", ascii_digits=ascii_digits)
|
||||
parts = []
|
||||
for item in income_totals:
|
||||
parts.append(f"{self.format_number(item['amount'], ascii_digits=ascii_digits)} {item['currency']}")
|
||||
currency = self.currency_label(item["currency"])
|
||||
parts.append(
|
||||
f"{self.format_amount_for_currency(item['amount'], item['currency'], ascii_digits=ascii_digits)} {currency}"
|
||||
)
|
||||
return " | ".join(parts)
|
||||
|
||||
def currency_label(self, code: str | None) -> str:
|
||||
raw = str(code or "").upper()
|
||||
return CURRENCY_LABELS.get(raw, {}).get(self.language, raw)
|
||||
|
||||
def shape(self, text: object) -> str:
|
||||
raw = str(text)
|
||||
if not any(start <= ord(char) <= end for char in raw for start, end in ARABIC_RANGES):
|
||||
@@ -158,7 +260,7 @@ def user_label(user_payload: dict | None, locale: ExportLocale, *, ascii_digits:
|
||||
|
||||
|
||||
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"
|
||||
base = sanitized[:31]
|
||||
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:
|
||||
locale = build_export_locale(job.filters.get("language"))
|
||||
report_data = build_table_report(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:
|
||||
per_user_reports = build_user_scoped_table_reports(job.requesting_user, job.filters)
|
||||
content = build_excel_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
|
||||
suffix = "xlsx"
|
||||
mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
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"
|
||||
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,217 +1,111 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from apps.notifications.services import store as notification_store
|
||||
from apps.reports.models import ReportExportJob
|
||||
from apps.reports.tasks import cleanup_expired_report_exports_task, generate_report_export_task
|
||||
from apps.time_entries.models import TimeEntry
|
||||
from apps.reports.tasks import (
|
||||
cleanup_expired_report_exports_task,
|
||||
generate_report_export_task,
|
||||
)
|
||||
from apps.users.models import User
|
||||
from apps.workspaces.models import Workspace
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def pipeline(self):
|
||||
return self
|
||||
class ReportTaskTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.owner = User.objects.create_user(
|
||||
mobile="09129990001",
|
||||
password="secret123",
|
||||
first_name="Owner",
|
||||
last_name="User",
|
||||
)
|
||||
cls.workspace = Workspace.objects.create(name="Exports", owner=cls.owner)
|
||||
|
||||
def zadd(self, *args, **kwargs):
|
||||
return self
|
||||
def test_generate_excel_export_marks_job_complete_and_sends_notification(self):
|
||||
job = ReportExportJob.objects.create(
|
||||
requesting_user=self.owner,
|
||||
workspace=self.workspace,
|
||||
export_type=ReportExportJob.ExportType.EXCEL,
|
||||
filters={
|
||||
"workspace": str(self.workspace.id),
|
||||
"period": "this_month",
|
||||
"from_date": "2026-04-01",
|
||||
"to_date": "2026-04-30",
|
||||
"user": str(self.owner.id),
|
||||
"client": None,
|
||||
"project": None,
|
||||
"tags": [],
|
||||
"language": "en",
|
||||
},
|
||||
)
|
||||
|
||||
def hset(self, *args, **kwargs):
|
||||
return self
|
||||
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))
|
||||
|
||||
def sadd(self, *args, **kwargs):
|
||||
return self
|
||||
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")
|
||||
|
||||
def execute(self):
|
||||
return []
|
||||
def test_generate_pdf_export_failure_marks_job_failed_and_notifies(self):
|
||||
job = ReportExportJob.objects.create(
|
||||
requesting_user=self.owner,
|
||||
workspace=self.workspace,
|
||||
export_type=ReportExportJob.ExportType.PDF,
|
||||
filters={
|
||||
"workspace": str(self.workspace.id),
|
||||
"period": "this_month",
|
||||
"from_date": "2026-04-01",
|
||||
"to_date": "2026-04-30",
|
||||
"user": str(self.owner.id),
|
||||
"client": None,
|
||||
"project": None,
|
||||
"tags": [],
|
||||
"language": "fa",
|
||||
},
|
||||
)
|
||||
|
||||
def publish(self, *args, **kwargs):
|
||||
return None
|
||||
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))
|
||||
|
||||
def zrevrange(self, *args, **kwargs):
|
||||
return []
|
||||
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")
|
||||
|
||||
def hget(self, *args, **kwargs):
|
||||
return None
|
||||
def test_cleanup_expires_and_removes_files(self):
|
||||
job = ReportExportJob.objects.create(
|
||||
requesting_user=self.owner,
|
||||
workspace=self.workspace,
|
||||
export_type=ReportExportJob.ExportType.EXCEL,
|
||||
status=ReportExportJob.Status.COMPLETED,
|
||||
filters={},
|
||||
expires_at=timezone.now() - timezone.timedelta(days=1),
|
||||
)
|
||||
file_name = f"reports/exports/{job.id}-old.xlsx"
|
||||
job.file.save(file_name, ContentFile(b"old-data"), save=False)
|
||||
job.save(update_fields=["file", "updated_at"])
|
||||
|
||||
def zrem(self, *args, **kwargs):
|
||||
return 1
|
||||
removed = cleanup_expired_report_exports_task()
|
||||
job.refresh_from_db()
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def teammate_entry(workspace, teammate):
|
||||
return TimeEntry.objects.create(
|
||||
workspace=workspace,
|
||||
user=teammate,
|
||||
description="Team row",
|
||||
start_time="2026-04-13T08:00:00+03:30",
|
||||
end_time="2026-04-13T09:00:00+03:30",
|
||||
duration=timedelta(hours=1),
|
||||
is_billable=False,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
|
||||
def test_generate_export_creates_file_and_marks_job_complete(fake_redis, workspace, owner, time_entry):
|
||||
job = ReportExportJob.objects.create(
|
||||
requesting_user=owner,
|
||||
workspace=workspace,
|
||||
export_type=ReportExportJob.ExportType.EXCEL,
|
||||
filters={
|
||||
"workspace": str(workspace.id),
|
||||
"period": "this_month",
|
||||
"from_date": "2026-04-01",
|
||||
"to_date": "2026-04-30",
|
||||
"user": str(owner.id),
|
||||
"client": None,
|
||||
"project": None,
|
||||
"tags": [],
|
||||
"language": "en",
|
||||
},
|
||||
)
|
||||
|
||||
generate_report_export_task(str(job.id))
|
||||
job.refresh_from_db()
|
||||
|
||||
assert job.status == ReportExportJob.Status.COMPLETED
|
||||
assert bool(job.file)
|
||||
assert default_storage.exists(job.file.name)
|
||||
|
||||
|
||||
def test_generate_excel_export_adds_per_user_sheets_for_all_users_scope(
|
||||
fake_redis,
|
||||
workspace,
|
||||
owner,
|
||||
teammate,
|
||||
time_entry,
|
||||
teammate_entry,
|
||||
):
|
||||
job = ReportExportJob.objects.create(
|
||||
requesting_user=owner,
|
||||
workspace=workspace,
|
||||
export_type=ReportExportJob.ExportType.EXCEL,
|
||||
filters={
|
||||
"workspace": str(workspace.id),
|
||||
"period": "this_month",
|
||||
"from_date": "2026-04-01",
|
||||
"to_date": "2026-04-30",
|
||||
"user": None,
|
||||
"client": None,
|
||||
"project": None,
|
||||
"tags": [],
|
||||
"language": "en",
|
||||
},
|
||||
)
|
||||
|
||||
generate_report_export_task(str(job.id))
|
||||
job.refresh_from_db()
|
||||
|
||||
workbook = load_workbook(BytesIO(job.file.read()))
|
||||
assert workbook.sheetnames[0] == "Overall Report"
|
||||
assert any("Owner User" in sheet for sheet in workbook.sheetnames[1:])
|
||||
assert any("Team Mate" in sheet for sheet in workbook.sheetnames[1:])
|
||||
assert len(workbook.sheetnames) == 3
|
||||
|
||||
|
||||
def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owner, time_entry):
|
||||
job = ReportExportJob.objects.create(
|
||||
requesting_user=owner,
|
||||
workspace=workspace,
|
||||
export_type=ReportExportJob.ExportType.PDF,
|
||||
filters={
|
||||
"workspace": str(workspace.id),
|
||||
"period": "this_month",
|
||||
"from_date": "2026-04-01",
|
||||
"to_date": "2026-04-30",
|
||||
"user": str(owner.id),
|
||||
"client": None,
|
||||
"project": None,
|
||||
"tags": [],
|
||||
"language": "fa",
|
||||
},
|
||||
)
|
||||
|
||||
generate_report_export_task(str(job.id))
|
||||
job.refresh_from_db()
|
||||
|
||||
assert job.status == ReportExportJob.Status.COMPLETED
|
||||
assert job.file.read(4) == b"%PDF"
|
||||
|
||||
|
||||
def test_cleanup_expires_and_removes_files(fake_redis, workspace, owner):
|
||||
job = ReportExportJob.objects.create(
|
||||
requesting_user=owner,
|
||||
workspace=workspace,
|
||||
export_type=ReportExportJob.ExportType.EXCEL,
|
||||
status=ReportExportJob.Status.COMPLETED,
|
||||
filters={},
|
||||
expires_at=timezone.now() - timezone.timedelta(days=1),
|
||||
)
|
||||
file_name = f"reports/exports/{job.id}-old.xlsx"
|
||||
job.file.save(file_name, ContentFile(b"old-data"), save=False)
|
||||
job.save(update_fields=["file", "updated_at"])
|
||||
|
||||
removed = cleanup_expired_report_exports_task()
|
||||
job.refresh_from_db()
|
||||
|
||||
assert removed == 1
|
||||
assert job.status == ReportExportJob.Status.EXPIRED
|
||||
assert not default_storage.exists(file_name)
|
||||
self.assertEqual(removed, 1)
|
||||
self.assertEqual(job.status, ReportExportJob.Status.EXPIRED)
|
||||
self.assertFalse(default_storage.exists(file_name))
|
||||
|
||||
@@ -1,163 +1,484 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
from django.core.cache import cache
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from apps.clients.models import Client
|
||||
from apps.projects.models import Project
|
||||
from apps.tags.models import Tag
|
||||
from apps.time_entries.models import TimeEntry
|
||||
from apps.users.models import User
|
||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api_client():
|
||||
return APIClient()
|
||||
class ReportViewTests(APITestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.owner = User.objects.create_user(
|
||||
mobile="09128880001",
|
||||
password="secret123",
|
||||
first_name="Owner",
|
||||
)
|
||||
cls.admin = User.objects.create_user(
|
||||
mobile="09128880002",
|
||||
password="secret123",
|
||||
first_name="Admin",
|
||||
)
|
||||
cls.member = User.objects.create_user(
|
||||
mobile="09128880003",
|
||||
password="secret123",
|
||||
first_name="Member",
|
||||
)
|
||||
cls.workspace = Workspace.objects.create(name="Reports", owner=cls.owner)
|
||||
WorkspaceMembership.objects.create(
|
||||
workspace=cls.workspace,
|
||||
user=cls.admin,
|
||||
role=WorkspaceMembership.Role.ADMIN,
|
||||
is_active=True,
|
||||
)
|
||||
WorkspaceMembership.objects.create(
|
||||
workspace=cls.workspace,
|
||||
user=cls.member,
|
||||
role=WorkspaceMembership.Role.MEMBER,
|
||||
is_active=True,
|
||||
)
|
||||
cls.client_obj = Client.objects.create(workspace=cls.workspace, name="Acme")
|
||||
cls.project = Project.objects.create(
|
||||
workspace=cls.workspace,
|
||||
name="Website",
|
||||
client=cls.client_obj,
|
||||
)
|
||||
cls.tag = Tag.objects.create(
|
||||
workspace=cls.workspace,
|
||||
name="Design",
|
||||
color="#ffffff",
|
||||
)
|
||||
|
||||
entry_owner = TimeEntry.objects.create(
|
||||
workspace=cls.workspace,
|
||||
user=cls.owner,
|
||||
project=cls.project,
|
||||
description="Owner work",
|
||||
start_time="2026-04-10T08:00:00+03:30",
|
||||
end_time="2026-04-10T10:00:00+03:30",
|
||||
duration=timedelta(hours=2),
|
||||
is_billable=True,
|
||||
hourly_rate=Decimal("25.00"),
|
||||
currency="USD",
|
||||
)
|
||||
entry_owner.tags.add(cls.tag)
|
||||
|
||||
@pytest.fixture()
|
||||
def owner(db):
|
||||
return User.objects.create_user(mobile="09128880001", password="secret123", first_name="Owner")
|
||||
entry_member = TimeEntry.objects.create(
|
||||
workspace=cls.workspace,
|
||||
user=cls.member,
|
||||
project=cls.project,
|
||||
description="Member work",
|
||||
start_time="2026-04-11T09:00:00+03:30",
|
||||
end_time="2026-04-11T10:00:00+03:30",
|
||||
duration=timedelta(hours=1),
|
||||
is_billable=False,
|
||||
currency="USD",
|
||||
)
|
||||
entry_member.tags.add(cls.tag)
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
@pytest.fixture()
|
||||
def admin(db):
|
||||
return User.objects.create_user(mobile="09128880002", password="secret123", first_name="Admin")
|
||||
def test_member_only_sees_own_chart_report(self):
|
||||
self.client.force_authenticate(user=self.member)
|
||||
|
||||
with patch(
|
||||
"apps.reports.services.aggregation.timezone.localdate",
|
||||
return_value=date(2026, 4, 20),
|
||||
):
|
||||
response = self.client.get(
|
||||
"/api/reports/chart/",
|
||||
{"workspace": str(self.workspace.id), "period": "this_month"},
|
||||
)
|
||||
|
||||
@pytest.fixture()
|
||||
def member(db):
|
||||
return User.objects.create_user(mobile="09128880003", password="secret123", first_name="Member")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
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)
|
||||
|
||||
@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
|
||||
with patch(
|
||||
"apps.reports.services.aggregation.timezone.localdate",
|
||||
return_value=date(2026, 4, 20),
|
||||
):
|
||||
response = self.client.get(
|
||||
"/api/reports/chart/",
|
||||
{"workspace": str(self.workspace.id), "period": "this_month"},
|
||||
)
|
||||
|
||||
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)},
|
||||
)
|
||||
|
||||
@pytest.fixture()
|
||||
def client(workspace):
|
||||
return Client.objects.create(workspace=workspace, name="Acme")
|
||||
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/",
|
||||
{"workspace": str(self.workspace.id), "period": "this_month"},
|
||||
)
|
||||
|
||||
@pytest.fixture()
|
||||
def project(workspace, client):
|
||||
return Project.objects.create(workspace=workspace, name="Website", client=client)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
|
||||
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)
|
||||
|
||||
@pytest.fixture()
|
||||
def tag(workspace):
|
||||
return Tag.objects.create(workspace=workspace, name="Design", color="#ffffff")
|
||||
TimeEntry.objects.create(
|
||||
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",
|
||||
)
|
||||
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
@pytest.fixture()
|
||||
def time_entries(workspace, owner, member, project, tag):
|
||||
entry_owner = TimeEntry.objects.create(
|
||||
workspace=workspace,
|
||||
user=owner,
|
||||
project=project,
|
||||
description="Owner work",
|
||||
start_time="2026-04-10T08:00:00+03:30",
|
||||
end_time="2026-04-10T10:00:00+03:30",
|
||||
duration=timedelta(hours=2),
|
||||
is_billable=True,
|
||||
hourly_rate=Decimal("25.00"),
|
||||
currency="USD",
|
||||
)
|
||||
entry_owner.tags.add(tag)
|
||||
entry_member = TimeEntry.objects.create(
|
||||
workspace=workspace,
|
||||
user=member,
|
||||
project=project,
|
||||
description="Member work",
|
||||
start_time="2026-04-11T09:00:00+03:30",
|
||||
end_time="2026-04-11T10:00:00+03:30",
|
||||
duration=timedelta(hours=1),
|
||||
is_billable=False,
|
||||
currency="USD",
|
||||
)
|
||||
entry_member.tags.add(tag)
|
||||
return [entry_owner, entry_member]
|
||||
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,
|
||||
)
|
||||
|
||||
def test_member_only_sees_own_chart_report(api_client, member, workspace, time_entries):
|
||||
api_client.force_authenticate(user=member)
|
||||
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",
|
||||
)
|
||||
|
||||
response = api_client.get(
|
||||
"/api/reports/chart/",
|
||||
{"workspace": str(workspace.id), "period": "this_month"},
|
||||
)
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["summary"]["total_duration"] == "01:00:00"
|
||||
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)
|
||||
|
||||
def test_admin_can_request_combined_table_report(api_client, admin, workspace, time_entries):
|
||||
api_client.force_authenticate(user=admin)
|
||||
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",
|
||||
)
|
||||
|
||||
response = api_client.get(
|
||||
"/api/reports/table/",
|
||||
{"workspace": str(workspace.id), "period": "this_month"},
|
||||
)
|
||||
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),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["summary"]["total_duration"] == "03:00:00"
|
||||
assert len(response.data["days"]) == 2
|
||||
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)
|
||||
|
||||
def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace):
|
||||
api_client.force_authenticate(user=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,
|
||||
)
|
||||
|
||||
response = api_client.get(
|
||||
"/api/reports/chart/",
|
||||
{
|
||||
"workspace": str(workspace.id),
|
||||
"period": "period",
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-02-15",
|
||||
},
|
||||
)
|
||||
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),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
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)
|
||||
|
||||
def test_persian_this_month_uses_jalali_month_bounds(api_client, owner, workspace, project, monkeypatch):
|
||||
api_client.force_authenticate(user=owner)
|
||||
monkeypatch.setattr("apps.reports.services.aggregation.timezone.localdate", lambda: date(2026, 4, 27))
|
||||
response = self.client.get(
|
||||
"/api/reports/chart/",
|
||||
{
|
||||
"workspace": str(self.workspace.id),
|
||||
"period": "period",
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-02-15",
|
||||
},
|
||||
)
|
||||
|
||||
TimeEntry.objects.create(
|
||||
workspace=workspace,
|
||||
user=owner,
|
||||
project=project,
|
||||
description="Previous jalali month",
|
||||
start_time="2026-04-20T08:00:00+03:30",
|
||||
end_time="2026-04-20T09:00:00+03:30",
|
||||
duration=timedelta(hours=1),
|
||||
is_billable=False,
|
||||
currency="USD",
|
||||
)
|
||||
TimeEntry.objects.create(
|
||||
workspace=workspace,
|
||||
user=owner,
|
||||
project=project,
|
||||
description="Current jalali month",
|
||||
start_time="2026-04-21T08:00:00+03:30",
|
||||
end_time="2026-04-21T10:00:00+03:30",
|
||||
duration=timedelta(hours=2),
|
||||
is_billable=False,
|
||||
currency="USD",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
response = api_client.get(
|
||||
"/api/reports/table/",
|
||||
{"workspace": str(workspace.id), "period": "this_month", "language": "fa"},
|
||||
)
|
||||
def test_persian_this_month_uses_jalali_month_bounds(self):
|
||||
self.client.force_authenticate(user=self.owner)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["summary"]["total_duration"] == "02:00:00"
|
||||
assert response.data["scope"]["from_date"] == "2026-04-21"
|
||||
with patch(
|
||||
"apps.reports.services.aggregation.timezone.localdate",
|
||||
return_value=date(2026, 4, 27),
|
||||
):
|
||||
TimeEntry.objects.create(
|
||||
workspace=self.workspace,
|
||||
user=self.owner,
|
||||
project=self.project,
|
||||
description="Previous jalali month",
|
||||
start_time="2026-04-20T08:00:00+03:30",
|
||||
end_time="2026-04-20T09:00:00+03:30",
|
||||
duration=timedelta(hours=1),
|
||||
is_billable=False,
|
||||
currency="USD",
|
||||
)
|
||||
TimeEntry.objects.create(
|
||||
workspace=self.workspace,
|
||||
user=self.owner,
|
||||
project=self.project,
|
||||
description="Current jalali month",
|
||||
start_time="2026-04-21T08:00:00+03:30",
|
||||
end_time="2026-04-21T10:00:00+03:30",
|
||||
duration=timedelta(hours=2),
|
||||
is_billable=False,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
"/api/reports/table/",
|
||||
{
|
||||
"workspace": str(self.workspace.id),
|
||||
"period": "this_month",
|
||||
"language": "fa",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["summary"]["total_duration"], "02:00:00")
|
||||
self.assertEqual(response.data["scope"]["from_date"], "2026-04-21")
|
||||
|
||||
def test_table_report_cache_stays_until_time_entry_invalidation(self):
|
||||
self.client.force_authenticate(user=self.owner)
|
||||
url = "/api/reports/table/"
|
||||
params = {"workspace": str(self.workspace.id), "period": "this_month"}
|
||||
|
||||
with patch(
|
||||
"apps.reports.services.aggregation.timezone.localdate",
|
||||
return_value=date(2026, 4, 20),
|
||||
):
|
||||
first_response = self.client.get(url, params)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
self.assertEqual(first_response.data["summary"]["total_duration"], "03:00:00")
|
||||
|
||||
member_entry = TimeEntry.objects.get(description="Member work")
|
||||
TimeEntry.objects.filter(id=member_entry.id).update(duration=timedelta(hours=5))
|
||||
|
||||
with patch(
|
||||
"apps.reports.services.aggregation.timezone.localdate",
|
||||
return_value=date(2026, 4, 20),
|
||||
):
|
||||
cached_response = self.client.get(url, params)
|
||||
self.assertEqual(cached_response.status_code, 200)
|
||||
self.assertEqual(cached_response.data["summary"]["total_duration"], "03:00:00")
|
||||
|
||||
member_entry.refresh_from_db()
|
||||
member_entry.description = "Member work updated"
|
||||
member_entry.save(update_fields=["description"])
|
||||
|
||||
with patch(
|
||||
"apps.reports.services.aggregation.timezone.localdate",
|
||||
return_value=date(2026, 4, 20),
|
||||
):
|
||||
fresh_response = self.client.get(url, params)
|
||||
self.assertEqual(fresh_response.status_code, 200)
|
||||
self.assertEqual(fresh_response.data["summary"]["total_duration"], "07:00:00")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user