Compare commits

...

59 Commits

Author SHA1 Message Date
027afb7e23 feat(contacts): store contact submissions
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-06-07 14:09:38 +03:30
170ec90ec1 fix(demo): block external account actions
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-06-07 00:50:42 +03:30
30a324c6f4 feat(demo): add isolated demo environments 2026-06-07 00:49:58 +03:30
da40720a0f fix(reports): freeze first excel column
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-26 17:22:34 +03:30
948a8e1b75 fix(reports): improve excel summary table spacing 2026-05-26 17:20:18 +03:30
b5ddcb76aa fix(timezone): fix timer clock-skew
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-26 12:59:49 +03:30
20874b9968 feat(reports): improve summary rates and export formatting
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-26 12:15:44 +03:30
af9facce7e feat(rates): record hourly rate history 2026-05-26 12:15:27 +03:30
e42e0612aa feat(media): add client and project thumbnails 2026-05-26 12:15:09 +03:30
f99e883f12 feat(reports): sort exported breakdown tables
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-25 00:10:28 +03:30
d18fdb1454 refactor(reports): replace escaped persian export labels
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-24 11:16:59 +03:30
5500badc6a refactor(users): replace escaped persian auth messages 2026-05-24 11:01:50 +03:30
2a0fa22be6 feat(projects): support implicit-access roles in rates modal 2026-05-24 10:18:31 +03:30
22e08a099c fix(reports): refine financial export summaries
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-23 20:13:35 +03:30
59cf62bc73 feat(reports): load user summaries on demand 2026-05-23 19:48:32 +03:30
0d6c6a4f09 feat(workspaces): add current user rates endpoint 2026-05-23 19:43:10 +03:30
181a135df9 feat(projects): add project-specific member rates 2026-05-23 18:29:00 +03:30
b79fd73403 fix(oauth): add callback error page for google oauth flow
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-22 01:01:21 +03:30
4d05d4d590 fix(users): trace google oauth redirect mismatches
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-21 19:12:45 +03:30
8d2f876c82 feat(reports): add uncategorized dual-share exports 2026-05-21 19:10:33 +03:30
e234eac26d fix(time-entries): use server time for running timers 2026-05-21 13:01:51 +03:30
0fea265cfb test(users): cover google signup otp gating
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-14 23:24:09 +03:30
4a6f6a08fb fix(users): require otp verification before google signup 2026-05-14 23:24:09 +03:30
837f5bb49e feat(admin): manage user social account links 2026-05-14 23:00:11 +03:30
aa0b0c8686 fix(admin): add soft delete filter to backend admins
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-14 22:51:57 +03:30
3019f59d3a fix(users): sync google profile data to user records
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-14 21:39:47 +03:30
388d4e0e7f test(users): cover google oauth identity safety
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-14 21:18:11 +03:30
d75c19bb6b feat(users): add google social account audit command 2026-05-14 21:17:47 +03:30
cacf6114d1 fix(users): harden google oauth account resolution 2026-05-14 21:17:37 +03:30
09d2015351 feat(users): normalize email identity storage 2026-05-14 21:17:25 +03:30
bb06762377 ci(backend): add gitea actions pipeline
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-14 18:18:25 +03:30
d4a52d6f3b feat(reports): refine exports and restore project access 2026-05-14 17:06:35 +03:30
77c07adec8 feat(reports): support multi-user chart series 2026-05-13 09:59:23 +03:30
f9c4c06531 feat(users): return otp expiry metadata 2026-05-13 09:58:58 +03:30
d1c4889d22 feat(users): apply django password validators in auth flows 2026-05-03 20:02:14 +03:30
f04e9ba828 fix(exceptions): change exception message string to exclude field_name 2026-05-03 18:22:36 +03:30
8ff1e4fa61 fix(users): validate password reset mobile input 2026-05-03 17:17:18 +03:30
0823267544 chore(readme): add README.md 2026-05-01 10:48:47 +03:30
df9a183823 test(reports): freeze date-sensitive report view cases 2026-05-01 01:54:13 +03:30
fb15a16204 feat(users): add google oauth login flow 2026-05-01 01:54:02 +03:30
99eb4c2594 perf(db): add targeted composite indexes 2026-04-30 16:13:35 +03:30
054bb5a582 feat(cache): add targeted server-side response caching 2026-04-30 16:13:12 +03:30
08e1793765 feat(throttling): add auth throttling and structured cooldown errors 2026-04-30 15:29:44 +03:30
3152284cf3 test(backend): add coverage for services tasks and apis 2026-04-30 12:44:24 +03:30
8774a4d4dc test(backend): convert existing app suites to unittest 2026-04-30 12:41:54 +03:30
204225dd16 test(backend): switch to django test runner 2026-04-30 12:41:38 +03:30
a2de2a133c fix(users): skip sms delivery when api key is unset 2026-04-29 20:19:13 +03:30
ec199a0e99 feat(projects): add client strip filtering and page refresh 2026-04-29 00:53:54 +03:30
ef05f0a89e feat(reports): add daily rate to report tables and exports 2026-04-28 20:26:20 +03:30
1cd948592c refactor(projects): remove project membership access model 2026-04-28 19:35:24 +03:30
71924ce6fb feat(logs): add workspace activity log api 2026-04-28 18:51:42 +03:30
c8a118788b feat(reports): include workspace thumbnail in pdf exports 2026-04-28 11:38:43 +03:30
315f2ca728 feat(workspaces): add thumbnail upload and lifecycle support 2026-04-28 11:38:35 +03:30
76f02dc259 feat(workspaces): expose role-aware membership details 2026-04-28 10:46:15 +03:30
afb1a55570 fix(permissions): restrict deletes and admin member management 2026-04-28 10:02:37 +03:30
02c9c17c30 fix(time-entries): preserve deleted tags in timesheet edits 2026-04-27 22:58:27 +03:30
7bd60fd641 fix(reports): localize and group exported income values 2026-04-27 21:14:02 +03:30
208e81139b fix(reports): use persian month buckets in chart data 2026-04-27 16:43:54 +03:30
e26263e93f feat(reports): add localized workspace reports and exports 2026-04-27 16:15:41 +03:30
178 changed files with 14488 additions and 2414 deletions

11
.coveragerc Normal file
View File

@@ -0,0 +1,11 @@
[run]
branch = True
source =
apps
omit =
*/migrations/*
*/tests/*
[report]
show_missing = True
skip_covered = False

View File

@@ -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

View 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
View File

@@ -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
View 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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View 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/'),
),
]

View File

@@ -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,
)

View File

@@ -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

View File

@@ -0,0 +1 @@

View 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)

View 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)

View File

@@ -0,0 +1 @@

56
apps/contacts/admin.py Normal file
View 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()

View File

@@ -0,0 +1 @@

View 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

View File

@@ -0,0 +1,5 @@
from rest_framework.throttling import AnonRateThrottle
class ContactSubmissionThrottle(AnonRateThrottle):
scope = "contact_submission"

View 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"),
]

View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ContactsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.contacts"

View 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"),
],
},
),
]

View File

@@ -0,0 +1 @@

36
apps/contacts/models.py Normal file
View 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}"

View File

@@ -0,0 +1 @@

View 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
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DemosConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.demos"

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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)."))

View 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"),
),
]

View File

@@ -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),
),
]

View File

@@ -0,0 +1 @@

39
apps/demos/models.py Normal file
View 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
View 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
View 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()

View File

@@ -0,0 +1 @@

View 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
View File

2
apps/logs/admin.py Normal file
View File

@@ -0,0 +1,2 @@
"""Admin registrations for apps.logs live on django-auditlog."""

View 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.")

View 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
View 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
View 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
View 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
View 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)

View File

2
apps/logs/models.py Normal file
View File

@@ -0,0 +1,2 @@
"""Workspace logs are backed by django-auditlog models."""

View 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",
]

View 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())

View 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
View 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(),
)

View File

@@ -0,0 +1 @@

View 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}&section=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"])
)

View File

@@ -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",
]

View File

@@ -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,
},
},
)

View File

@@ -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)

View File

@@ -0,0 +1 @@

View 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

View File

@@ -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), [])

View File

@@ -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")

View 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
)

View File

@@ -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"]))

View File

@@ -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",
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)),

View File

@@ -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)

View File

@@ -0,0 +1,13 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("projects", "0001_initial"),
]
operations = [
migrations.DeleteModel(
name="ProjectMembership",
),
]

View File

@@ -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'),
),
]

View 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')],
},
),
]

View 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/'),
),
]

View File

@@ -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"),
]

View 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

View File

@@ -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

View File

@@ -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:

View 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

View File

@@ -0,0 +1 @@

View 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)

View 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)

View 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
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ReportsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.reports"

Binary file not shown.

Binary file not shown.

View 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')],
},
),
]

View File

@@ -0,0 +1 @@

96
apps/reports/models.py Normal file
View 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,
},
)

View 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",
]

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