Compare commits
59 Commits
fadf898486
...
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 | |||
| 208e81139b | |||
| e26263e93f |
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())
|
||||
1
apps/reports/__init__.py
Normal file
1
apps/reports/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
apps/reports/api/__init__.py
Normal file
1
apps/reports/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
39
apps/reports/api/serializers.py
Normal file
39
apps/reports/api/serializers.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.reports.models import ReportExportJob
|
||||
from apps.reports.services.aggregation import ALLOWED_PERIODS
|
||||
|
||||
|
||||
class ReportExportCreateSerializer(serializers.Serializer):
|
||||
workspace = serializers.UUIDField()
|
||||
period = serializers.ChoiceField(choices=sorted(ALLOWED_PERIODS))
|
||||
from_date = serializers.DateField(required=False, allow_null=True)
|
||||
to_date = serializers.DateField(required=False, allow_null=True)
|
||||
user = serializers.UUIDField(required=False, allow_null=True)
|
||||
client = serializers.UUIDField(required=False, allow_null=True)
|
||||
project = serializers.UUIDField(required=False, allow_null=True)
|
||||
tags = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
)
|
||||
language = serializers.ChoiceField(choices=("en", "fa"), required=False, default="en")
|
||||
export_type = serializers.ChoiceField(choices=ReportExportJob.ExportType.choices)
|
||||
|
||||
|
||||
class ReportExportJobSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ReportExportJob
|
||||
fields = (
|
||||
"id",
|
||||
"workspace",
|
||||
"export_type",
|
||||
"status",
|
||||
"filters",
|
||||
"file_name",
|
||||
"error_message",
|
||||
"expires_at",
|
||||
"completed_at",
|
||||
"created_at",
|
||||
)
|
||||
read_only_fields = fields
|
||||
21
apps/reports/api/urls.py
Normal file
21
apps/reports/api/urls.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.reports.api.views import (
|
||||
ReportChartView,
|
||||
ReportDayDetailsView,
|
||||
ReportExportJobViewSet,
|
||||
ReportTableView,
|
||||
ReportUserSummaryView,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"exports", ReportExportJobViewSet, basename="report-export-job")
|
||||
|
||||
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)),
|
||||
]
|
||||
163
apps/reports/api/views.py
Normal file
163
apps/reports/api/views.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse, Http404
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.reports.api.serializers import (
|
||||
ReportExportCreateSerializer,
|
||||
ReportExportJobSerializer,
|
||||
)
|
||||
from apps.reports.models import ReportExportJob
|
||||
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):
|
||||
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_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):
|
||||
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_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):
|
||||
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_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(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = ReportExportJobSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return ReportExportJob.objects.filter(requesting_user=self.request.user, is_deleted=False)
|
||||
|
||||
@extend_schema(request=ReportExportCreateSerializer, responses=ReportExportJobSerializer)
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = ReportExportCreateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
filters = load_report_filters(request.user, serializer.validated_data)
|
||||
|
||||
job = ReportExportJob.objects.create(
|
||||
requesting_user=request.user,
|
||||
workspace=filters.workspace,
|
||||
export_type=serializer.validated_data["export_type"],
|
||||
filters={
|
||||
"workspace": str(filters.workspace.id),
|
||||
"period": filters.period,
|
||||
"from_date": filters.from_date.isoformat(),
|
||||
"to_date": filters.to_date.isoformat(),
|
||||
"user": filters.user_id,
|
||||
"client": filters.client_id,
|
||||
"project": filters.project_id,
|
||||
"tags": filters.tag_ids,
|
||||
"language": serializer.validated_data.get("language", "en"),
|
||||
},
|
||||
status=ReportExportJob.Status.PENDING,
|
||||
)
|
||||
generate_report_export_task.delay(str(job.id))
|
||||
output = ReportExportJobSerializer(job)
|
||||
return Response(output.data, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="download")
|
||||
def download(self, request, pk=None):
|
||||
job = self.get_object()
|
||||
if job.status != ReportExportJob.Status.COMPLETED or not job.file:
|
||||
raise Http404("Export file is not available.")
|
||||
if job.expires_at and job.expires_at <= timezone.now():
|
||||
raise Http404("Export file has expired.")
|
||||
response = FileResponse(
|
||||
job.file.open("rb"),
|
||||
as_attachment=True,
|
||||
filename=job.file_name or job.file.name.split("/")[-1],
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def build_export_action_url(job: ReportExportJob) -> str:
|
||||
path = reverse("report-export-job-download", kwargs={"pk": job.id})
|
||||
if settings.BASE_URL:
|
||||
return f"{settings.BASE_URL.rstrip('/')}{path}"
|
||||
return path
|
||||
7
apps/reports/apps.py
Normal file
7
apps/reports/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReportsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.reports"
|
||||
|
||||
BIN
apps/reports/assets/fonts/Vazirmatn-Bold.ttf
Normal file
BIN
apps/reports/assets/fonts/Vazirmatn-Bold.ttf
Normal file
Binary file not shown.
BIN
apps/reports/assets/fonts/Vazirmatn-Regular.ttf
Normal file
BIN
apps/reports/assets/fonts/Vazirmatn-Regular.ttf
Normal file
Binary file not shown.
47
apps/reports/migrations/0001_initial.py
Normal file
47
apps/reports/migrations/0001_initial.py
Normal file
@@ -0,0 +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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
1
apps/reports/migrations/__init__.py
Normal file
1
apps/reports/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
96
apps/reports/models.py
Normal file
96
apps/reports/models.py
Normal file
@@ -0,0 +1,96 @@
|
||||
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
|
||||
|
||||
|
||||
class ReportExportJob(BaseModel):
|
||||
class ExportType(models.TextChoices):
|
||||
EXCEL = "excel", "Excel"
|
||||
PDF = "pdf", "PDF"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
PROCESSING = "processing", "Processing"
|
||||
COMPLETED = "completed", "Completed"
|
||||
FAILED = "failed", "Failed"
|
||||
EXPIRED = "expired", "Expired"
|
||||
|
||||
requesting_user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="report_export_jobs",
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"workspaces.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="report_export_jobs",
|
||||
)
|
||||
export_type = models.CharField(max_length=16, choices=ExportType.choices)
|
||||
status = models.CharField(
|
||||
max_length=16,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING,
|
||||
)
|
||||
filters = models.JSONField(default=dict)
|
||||
file = models.FileField(upload_to="reports/exports/", blank=True, null=True)
|
||||
file_name = models.CharField(max_length=255, blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
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"),
|
||||
]
|
||||
|
||||
def mark_processing(self):
|
||||
self.status = self.Status.PROCESSING
|
||||
self.error_message = ""
|
||||
self.save(update_fields=["status", "error_message", "updated_at"])
|
||||
|
||||
def mark_completed(self, *, file_name: str):
|
||||
self.status = self.Status.COMPLETED
|
||||
self.file_name = file_name
|
||||
self.completed_at = timezone.now()
|
||||
self.expires_at = self.completed_at + timezone.timedelta(days=7)
|
||||
self.error_message = ""
|
||||
self.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"file_name",
|
||||
"completed_at",
|
||||
"expires_at",
|
||||
"error_message",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
def mark_failed(self, message: str):
|
||||
self.status = self.Status.FAILED
|
||||
self.error_message = message[:2000]
|
||||
self.save(update_fields=["status", "error_message", "updated_at"])
|
||||
|
||||
def mark_expired(self):
|
||||
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,
|
||||
},
|
||||
)
|
||||
17
apps/reports/services/__init__.py
Normal file
17
apps/reports/services/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"load_report_filters",
|
||||
"build_chart_report",
|
||||
"build_table_report",
|
||||
"build_user_summary_report",
|
||||
"build_user_scoped_table_reports",
|
||||
"build_day_details_report",
|
||||
]
|
||||
1116
apps/reports/services/aggregation.py
Normal file
1116
apps/reports/services/aggregation.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user