Compare commits

...

49 Commits

Author SHA1 Message Date
8c7745c935 fix(workspaces): return import validation codes
Some checks are pending
Backend CI/CD / test (push) Waiting to run
Backend CI/CD / deploy (push) Blocked by required conditions
2026-06-19 01:48:12 +03:30
95f5e85e44 feat(workspaces): add bulk member import endpoints
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-06-18 22:53:34 +03:30
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
133 changed files with 11793 additions and 2354 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

@@ -42,3 +42,7 @@ TIME_ZONE=Asia/Tehran
SMS_APIKEY= SMS_APIKEY=
BASE_URL= BASE_URL=
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_REDIRECT_URI=http://localhost:8000/api/users/oauth/google/callback/
GOOGLE_OAUTH_FRONTEND_CALLBACK_URL=http://localhost:5173/auth/google/callback

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"

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

@@ -13,9 +13,33 @@ class ClientSerializer(BaseModelSerializer):
"workspace", "workspace",
"name", "name",
"notes", "notes",
"thumbnail",
) )
read_only_fields = fields read_only_fields = fields
def to_representation(self, instance):
data = super().to_representation(instance)
request = self.context.get("request")
if instance.thumbnail:
thumbnail_url = instance.thumbnail.url
data["thumbnail"] = request.build_absolute_uri(thumbnail_url) if request else thumbnail_url
else:
data["thumbnail"] = None
return data
def validate_thumbnail(value):
if value is None:
return value
max_bytes = 2 * 1024 * 1024
if getattr(value, "size", 0) > max_bytes:
raise serializers.ValidationError("Image size must be 2MB or less.")
content_type = (getattr(value, "content_type", "") or "").lower()
allowed_types = {"image/jpeg", "image/png", "image/webp"}
if content_type and content_type not in allowed_types:
raise serializers.ValidationError("Unsupported image type. Use JPG, PNG, or WebP.")
return value
class ClientCreateSerializer(serializers.Serializer): class ClientCreateSerializer(serializers.Serializer):
""" """
@@ -24,6 +48,10 @@ class ClientCreateSerializer(serializers.Serializer):
workspace_id = serializers.UUIDField() workspace_id = serializers.UUIDField()
name = serializers.CharField(max_length=255) name = serializers.CharField(max_length=255)
notes = serializers.CharField(allow_blank=True, required=False, default="") notes = serializers.CharField(allow_blank=True, required=False, default="")
thumbnail = serializers.ImageField(required=False, allow_null=True)
def validate_thumbnail(self, value):
return validate_thumbnail(value)
class ClientUpdateSerializer(serializers.Serializer): class ClientUpdateSerializer(serializers.Serializer):
@@ -32,3 +60,8 @@ class ClientUpdateSerializer(serializers.Serializer):
""" """
name = serializers.CharField(max_length=255, required=False) name = serializers.CharField(max_length=255, required=False)
notes = serializers.CharField(allow_blank=True, required=False) notes = serializers.CharField(allow_blank=True, required=False)
thumbnail = serializers.ImageField(required=False, allow_null=True)
clear_thumbnail = serializers.BooleanField(required=False, default=False)
def validate_thumbnail(self, value):
return validate_thumbnail(value)

View File

@@ -63,10 +63,11 @@ class ClientViewSet(ModelViewSet):
user=request.user, user=request.user,
workspace_id=serializer.validated_data["workspace_id"], workspace_id=serializer.validated_data["workspace_id"],
name=serializer.validated_data["name"], name=serializer.validated_data["name"],
notes=serializer.validated_data.get("notes", "") notes=serializer.validated_data.get("notes", ""),
thumbnail=serializer.validated_data.get("thumbnail"),
) )
output_serializer = ClientSerializer(client) output_serializer = ClientSerializer(client, context={"request": request})
return Response(output_serializer.data, status=status.HTTP_201_CREATED) return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
@@ -82,10 +83,12 @@ class ClientViewSet(ModelViewSet):
updated_client = update_client( updated_client = update_client(
client=client, client=client,
name=serializer.validated_data.get("name"), name=serializer.validated_data.get("name"),
notes=serializer.validated_data.get("notes") notes=serializer.validated_data.get("notes"),
thumbnail=serializer.validated_data.get("thumbnail"),
clear_thumbnail=serializer.validated_data.get("clear_thumbnail", False),
) )
output_serializer = ClientSerializer(updated_client) output_serializer = ClientSerializer(updated_client, context={"request": request})
return Response(output_serializer.data, status=status.HTTP_200_OK) return Response(output_serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):

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

@@ -17,6 +17,8 @@ class Client(BaseModel):
notes = models.TextField(blank=True) notes = models.TextField(blank=True)
thumbnail = models.ImageField(upload_to="profile/clients/", blank=True, null=True)
class Meta: class Meta:
db_table = "client" db_table = "client"
ordering = ("-updated_at", "-created_at") ordering = ("-updated_at", "-created_at")

View File

@@ -3,7 +3,7 @@ from apps.clients.models import Client
from apps.workspaces.models import WorkspaceMembership from apps.workspaces.models import WorkspaceMembership
def create_client(user, workspace_id, name, notes=""): def create_client(user, workspace_id, name, notes="", thumbnail=None):
""" """
Creates a new client after validating workspace membership and name uniqueness. Creates a new client after validating workspace membership and name uniqueness.
""" """
@@ -23,12 +23,13 @@ def create_client(user, workspace_id, name, notes=""):
workspace_id=workspace_id, workspace_id=workspace_id,
name=name, name=name,
notes=notes, notes=notes,
thumbnail=thumbnail,
created_by=user, created_by=user,
updated_by=user, updated_by=user,
) )
def update_client(client, name=None, notes=None): def update_client(client, name=None, notes=None, thumbnail=None, clear_thumbnail=False):
""" """
Updates an existing client while validating name uniqueness within the workspace. Updates an existing client while validating name uniqueness within the workspace.
""" """
@@ -40,5 +41,17 @@ def update_client(client, name=None, notes=None):
if notes is not None: if notes is not None:
client.notes = notes client.notes = notes
client.save(update_fields=["name", "notes", "updated_at"]) old_thumbnail_name = client.thumbnail.name if client.thumbnail else None
if clear_thumbnail and client.thumbnail:
client.thumbnail.delete(save=False)
client.thumbnail = None
if thumbnail is not None:
client.thumbnail = thumbnail
client.save(update_fields=["name", "notes", "thumbnail", "updated_at"])
if old_thumbnail_name and client.thumbnail and client.thumbnail.name != old_thumbnail_name:
storage = client.thumbnail.storage
if storage.exists(old_thumbnail_name):
storage.delete(old_thumbnail_name)
return client return client

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

View File

@@ -1,181 +1,161 @@
from __future__ import annotations
from datetime import timedelta
import pytest
from auditlog.models import LogEntry from auditlog.models import LogEntry
from rest_framework_simplejwt.tokens import AccessToken from rest_framework_simplejwt.tokens import AccessToken
from rest_framework.test import APIClient from rest_framework.test import APITestCase
from apps.reports.models import ReportExportJob from apps.reports.models import ReportExportJob
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import Workspace, WorkspaceMembership
@pytest.fixture() class WorkspaceLogViewTests(APITestCase):
def api_client(): @classmethod
return APIClient() def setUpTestData(cls):
cls.owner = cls._user(1)
cls.admin = cls._user(2)
cls.member = cls._user(3)
cls.outsider = cls._user(4)
cls.workspace = Workspace.objects.create(
name="Logs WS",
description="",
owner=cls.owner,
)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.admin,
role=WorkspaceMembership.Role.ADMIN,
is_active=True,
)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
def _user(index: int) -> User: @staticmethod
return User.objects.create_user( def _user(index):
mobile=f"093355500{index:02d}", return User.objects.create_user(
password="secret123", mobile=f"093355500{index:02d}",
first_name=f"Log{index}", password="secret123",
last_name="User", first_name=f"Log{index}",
) last_name="User",
)
@staticmethod
def _auth_headers(user):
token = str(AccessToken.for_user(user))
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
@pytest.fixture() def _create_tag(self, user, *, name="Audit Tag"):
def owner(db): return self.client.post(
return _user(1) "/api/tags/",
{
"workspace_id": str(self.workspace.id),
"name": name,
"color": "#123456",
},
format="json",
**self._auth_headers(user),
)
def test_owner_and_admin_can_list_workspace_logs(self):
create_response = self._create_tag(self.owner)
self.assertEqual(create_response.status_code, 201)
@pytest.fixture() owner_response = self.client.get(
def admin(db): f"/api/logs/?workspace={self.workspace.id}",
return _user(2) **self._auth_headers(self.owner),
)
admin_response = self.client.get(
f"/api/logs/?workspace={self.workspace.id}",
**self._auth_headers(self.admin),
)
self.assertEqual(owner_response.status_code, 200)
self.assertEqual(admin_response.status_code, 200)
self.assertEqual(owner_response.data["items"][0]["section"], "tags")
@pytest.fixture() def test_member_and_non_member_cannot_list_workspace_logs(self):
def member(db): self._create_tag(self.owner)
return _user(3)
member_response = self.client.get(
f"/api/logs/?workspace={self.workspace.id}",
**self._auth_headers(self.member),
)
outsider_response = self.client.get(
f"/api/logs/?workspace={self.workspace.id}",
**self._auth_headers(self.outsider),
)
@pytest.fixture() self.assertEqual(member_response.status_code, 403)
def outsider(db): self.assertEqual(outsider_response.status_code, 403)
return _user(4)
def test_jwt_authenticated_writes_capture_actor_and_workspace_metadata(self):
response = self._create_tag(self.owner, name="JWT Tag")
self.assertEqual(response.status_code, 201)
@pytest.fixture() log_entry = LogEntry.objects.filter(content_type__app_label="tags").latest(
def workspace(owner, admin, member): "timestamp"
workspace = Workspace.objects.create(name="Logs WS", description="", owner=owner) )
WorkspaceMembership.objects.create(
workspace=workspace,
user=admin,
role=WorkspaceMembership.Role.ADMIN,
is_active=True,
)
WorkspaceMembership.objects.create(
workspace=workspace,
user=member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
return workspace
self.assertEqual(log_entry.actor_id, self.owner.id)
self.assertEqual(log_entry.additional_data["workspace_id"], str(self.workspace.id))
self.assertEqual(log_entry.additional_data["section"], "tags")
def _auth_headers(user: User) -> dict: def test_logs_support_section_filter_and_detail(self):
token = str(AccessToken.for_user(user)) tag_response = self._create_tag(self.owner, name="Filtered Tag")
return {"HTTP_AUTHORIZATION": f"Bearer {token}"} self.assertEqual(tag_response.status_code, 201)
list_response = self.client.get(
f"/api/logs/?workspace={self.workspace.id}&section=tags",
**self._auth_headers(self.owner),
)
def _create_tag(client: APIClient, user: User, workspace: Workspace, *, name="Audit Tag"): self.assertEqual(list_response.status_code, 200)
return client.post( self.assertTrue(list_response.data["items"])
"/api/tags/", log_id = list_response.data["items"][0]["id"]
{"workspace_id": str(workspace.id), "name": name, "color": "#123456"},
format="json",
**_auth_headers(user),
)
detail_response = self.client.get(
f"/api/logs/{log_id}/",
**self._auth_headers(self.owner),
)
@pytest.mark.django_db self.assertEqual(detail_response.status_code, 200)
def test_owner_and_admin_can_list_workspace_logs(api_client, owner, admin, workspace): self.assertEqual(detail_response.data["target"]["name"], "Filtered Tag")
create_response = _create_tag(api_client, owner, workspace) self.assertTrue(detail_response.data["changes"])
assert create_response.status_code == 201
owner_response = api_client.get( def test_soft_delete_and_actorless_background_logs_are_filtered(self):
f"/api/logs/?workspace={workspace.id}", create_response = self._create_tag(self.owner, name="Delete Me")
**_auth_headers(owner), self.assertEqual(create_response.status_code, 201)
) tag_id = create_response.data["id"]
admin_response = api_client.get(
f"/api/logs/?workspace={workspace.id}",
**_auth_headers(admin),
)
assert owner_response.status_code == 200 delete_response = self.client.delete(
assert admin_response.status_code == 200 f"/api/tags/{tag_id}/",
assert owner_response.data["items"][0]["section"] == "tags" **self._auth_headers(self.owner),
)
self.assertEqual(delete_response.status_code, 204)
ReportExportJob.objects.create(
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.PDF,
filters={"workspace": str(self.workspace.id)},
status=ReportExportJob.Status.PENDING,
)
@pytest.mark.django_db response = self.client.get(
def test_member_and_non_member_cannot_list_workspace_logs(api_client, owner, member, outsider, workspace): f"/api/logs/?workspace={self.workspace.id}&event=delete",
_create_tag(api_client, owner, workspace) **self._auth_headers(self.owner),
)
member_response = api_client.get(
f"/api/logs/?workspace={workspace.id}",
**_auth_headers(member),
)
outsider_response = api_client.get(
f"/api/logs/?workspace={workspace.id}",
**_auth_headers(outsider),
)
assert member_response.status_code == 403
assert outsider_response.status_code == 403
@pytest.mark.django_db
def test_jwt_authenticated_writes_capture_actor_and_workspace_metadata(api_client, owner, workspace):
response = _create_tag(api_client, owner, workspace, name="JWT Tag")
assert response.status_code == 201
log_entry = LogEntry.objects.filter(content_type__app_label="tags").latest("timestamp")
assert log_entry.actor_id == owner.id
assert log_entry.additional_data["workspace_id"] == str(workspace.id)
assert log_entry.additional_data["section"] == "tags"
@pytest.mark.django_db
def test_logs_support_section_filter_and_detail(api_client, owner, workspace):
tag_response = _create_tag(api_client, owner, workspace, name="Filtered Tag")
assert tag_response.status_code == 201
list_response = api_client.get(
f"/api/logs/?workspace={workspace.id}&section=tags",
**_auth_headers(owner),
)
assert list_response.status_code == 200
assert list_response.data["items"]
log_id = list_response.data["items"][0]["id"]
detail_response = api_client.get(
f"/api/logs/{log_id}/",
**_auth_headers(owner),
)
assert detail_response.status_code == 200
assert detail_response.data["target"]["name"] == "Filtered Tag"
assert detail_response.data["changes"]
@pytest.mark.django_db
def test_soft_delete_and_actorless_background_logs_are_filtered(api_client, owner, workspace):
create_response = _create_tag(api_client, owner, workspace, name="Delete Me")
assert create_response.status_code == 201
tag_id = create_response.data["id"]
delete_response = api_client.delete(
f"/api/tags/{tag_id}/",
**_auth_headers(owner),
)
assert delete_response.status_code == 204
ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.PDF,
filters={"workspace": str(workspace.id)},
status=ReportExportJob.Status.PENDING,
)
response = api_client.get(
f"/api/logs/?workspace={workspace.id}&event=delete",
**_auth_headers(owner),
)
assert response.status_code == 200
assert any(item["event"] == "delete" and item["section"] == "tags" for item in response.data["items"])
assert all(item["section"] != "report_exports" for item in response.data["items"])
self.assertEqual(response.status_code, 200)
self.assertTrue(
any(
item["event"] == "delete" and item["section"] == "tags"
for item in response.data["items"]
)
)
self.assertTrue(
all(item["section"] != "report_exports" for item in response.data["items"])
)

View File

@@ -205,6 +205,18 @@ class RedisNotificationStore:
return True return True
return False return False
@classmethod
def clear_user(cls, user_id: str) -> int:
ids_key = cls._ids_key(user_id)
data_key = cls._data_key(user_id)
count = redis_client.zcard(ids_key)
pipe = redis_client.pipeline()
pipe.delete(ids_key)
pipe.delete(data_key)
pipe.srem(cls.USERS_KEY, user_id)
pipe.execute()
return int(count or 0)
@classmethod @classmethod
def mark_seen(cls, user_id: str, notif_id: str) -> dict | None: def mark_seen(cls, user_id: str, notif_id: str) -> dict | None:
data = cls.get(user_id, notif_id) data = cls.get(user_id, notif_id)

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,159 +1,137 @@
import pytest from django.test import TestCase
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.notifications.services import store as services from apps.notifications.services import store as services
from apps.notifications.services import RedisNotificationStore from apps.notifications.services import RedisNotificationStore
from apps.notifications.tests.test_services import FakeRedis from apps.notifications.tests.fakes import FakeRedis
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import Workspace, WorkspaceMembership
@pytest.fixture() class WorkspaceMembershipNotificationTests(TestCase):
def fake_redis(monkeypatch): @classmethod
redis = FakeRedis() def setUpTestData(cls):
monkeypatch.setattr(services, "redis_client", redis) cls.owner = cls._create_user(1)
return redis cls.member = cls._create_user(2)
@staticmethod
def _create_user(index):
return User.objects.create_user(
mobile=f"091200000{index:02d}",
password="secret123",
first_name=f"User{index}",
)
@pytest.fixture() def setUp(self):
def api_client(): self.client = APIClient()
return APIClient() self.fake_redis = FakeRedis()
self.original_redis_client = services.redis_client
services.redis_client = self.fake_redis
def tearDown(self):
services.redis_client = self.original_redis_client
def _create_user(index: int) -> User: @staticmethod
return User.objects.create_user( def _notifications_for(user):
mobile=f"091200000{index:02d}", notifications, _ = RedisNotificationStore.list(str(user.id), paginate=False)
password="secret123", return notifications
first_name=f"User{index}",
)
def test_workspace_create_notifies_initial_members_not_owner(self):
self.client.force_authenticate(user=self.owner)
def _notifications_for(user): response = self.client.post(
notifications, _ = RedisNotificationStore.list( "/api/workspaces/",
str(user.id), {
paginate=False, "name": "Ops",
) "description": "Workspace",
return notifications "members": [
{
"user_id": str(self.member.id),
"role": WorkspaceMembership.Role.ADMIN,
}
],
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(self._notifications_for(self.owner), [])
member_notifications = self._notifications_for(self.member)
self.assertEqual(len(member_notifications), 1)
self.assertEqual(member_notifications[0]["type"], "workspace_membership_added")
self.assertEqual(member_notifications[0]["meta"]["workspace_name"], "Ops")
self.assertEqual(
member_notifications[0]["meta"]["new_role"],
WorkspaceMembership.Role.ADMIN,
)
@pytest.fixture() def test_workspace_membership_crud_emits_all_expected_events(self):
def owner(db): workspace = Workspace.objects.create(name="Design", description="", owner=self.owner)
return _create_user(1) self.client.force_authenticate(user=self.owner)
create_response = self.client.post(
"/api/workspace-memberships/",
{
"workspace": str(workspace.id),
"user": str(self.member.id),
"role": WorkspaceMembership.Role.MEMBER,
"is_active": True,
},
format="json",
)
self.assertEqual(create_response.status_code, 201)
@pytest.fixture() membership_id = create_response.data["id"]
def member(db): notifications = self._notifications_for(self.member)
return _create_user(2) self.assertEqual(
[item["type"] for item in notifications],
["workspace_membership_added"],
)
role_response = self.client.patch(
f"/api/workspace-memberships/{membership_id}/",
{"role": WorkspaceMembership.Role.ADMIN},
format="json",
)
self.assertEqual(role_response.status_code, 200)
@pytest.fixture() deactivate_response = self.client.patch(
def another_member(db): f"/api/workspace-memberships/{membership_id}/",
return _create_user(3) {"is_active": False},
format="json",
)
self.assertEqual(deactivate_response.status_code, 200)
remove_response = self.client.delete(
f"/api/workspace-memberships/{membership_id}/"
)
self.assertEqual(remove_response.status_code, 204)
@pytest.fixture() notifications = self._notifications_for(self.member)
def third_member(db): self.assertEqual(
return _create_user(4) [item["type"] for item in notifications],
[
"workspace_membership_removed",
@pytest.fixture() "workspace_membership_deactivated",
def fourth_member(db): "workspace_membership_role_changed",
return _create_user(5) "workspace_membership_added",
def test_workspace_create_notifies_initial_members_not_owner(
fake_redis, api_client, owner, member
):
api_client.force_authenticate(user=owner)
response = api_client.post(
"/api/workspaces/",
{
"name": "Ops",
"description": "Workspace",
"members": [
{"user_id": str(member.id), "role": WorkspaceMembership.Role.ADMIN}
], ],
}, )
format="json",
)
assert response.status_code == 201 def test_workspace_membership_update_skips_self_notifications(self):
owner_notifications = _notifications_for(owner) workspace = Workspace.objects.create(name="Product", description="", owner=self.owner)
member_notifications = _notifications_for(member) owner_membership = WorkspaceMembership.objects.get(
workspace=workspace,
user=self.owner,
is_deleted=False,
)
self.client.force_authenticate(user=self.owner)
assert owner_notifications == [] response = self.client.patch(
assert len(member_notifications) == 1 f"/api/workspace-memberships/{owner_membership.id}/",
assert member_notifications[0]["type"] == "workspace_membership_added" {"role": WorkspaceMembership.Role.OWNER},
assert member_notifications[0]["meta"]["workspace_name"] == "Ops" format="json",
assert member_notifications[0]["meta"]["new_role"] == WorkspaceMembership.Role.ADMIN )
def test_workspace_membership_crud_emits_add_role_change_deactivate_and_remove(
fake_redis, api_client, owner, member
):
workspace = Workspace.objects.create(name="Design", description="", owner=owner)
api_client.force_authenticate(user=owner)
create_response = api_client.post(
"/api/workspace-memberships/",
{
"workspace": str(workspace.id),
"user": str(member.id),
"role": WorkspaceMembership.Role.MEMBER,
"is_active": True,
},
format="json",
)
assert create_response.status_code == 201
membership_id = create_response.data["id"]
notifications = _notifications_for(member)
assert [item["type"] for item in notifications] == ["workspace_membership_added"]
role_response = api_client.patch(
f"/api/workspace-memberships/{membership_id}/",
{"role": WorkspaceMembership.Role.ADMIN},
format="json",
)
assert role_response.status_code == 200
deactivate_response = api_client.patch(
f"/api/workspace-memberships/{membership_id}/",
{"is_active": False},
format="json",
)
assert deactivate_response.status_code == 200
remove_response = api_client.delete(f"/api/workspace-memberships/{membership_id}/")
assert remove_response.status_code == 204
notifications = _notifications_for(member)
assert [item["type"] for item in notifications] == [
"workspace_membership_removed",
"workspace_membership_deactivated",
"workspace_membership_role_changed",
"workspace_membership_added",
]
def test_workspace_membership_update_skips_self_notifications(
fake_redis, api_client, owner
):
workspace = Workspace.objects.create(name="Product", description="", owner=owner)
owner_membership = WorkspaceMembership.objects.get(
workspace=workspace,
user=owner,
is_deleted=False,
)
api_client.force_authenticate(user=owner)
response = api_client.patch(
f"/api/workspace-memberships/{owner_membership.id}/",
{"role": WorkspaceMembership.Role.OWNER},
format="json",
)
assert response.status_code == 403
assert _notifications_for(owner) == []
self.assertEqual(response.status_code, 403)
self.assertEqual(self._notifications_for(self.owner), [])

View File

@@ -1,200 +1,78 @@
import json from django.conf import settings
from collections import defaultdict from django.test import TestCase
import pytest
from apps.notifications.services import store as services from apps.notifications.services import store as services
from apps.notifications.services import RedisNotificationStore from apps.notifications.services import RedisNotificationStore
from apps.notifications.tests.fakes import FakeRedis
class FakePipeline: class RedisNotificationStoreTests(TestCase):
def __init__(self, client): def setUp(self):
self.client = client self.fake_redis = FakeRedis()
self.operations = [] self.original_redis_client = services.redis_client
services.redis_client = self.fake_redis
def __getattr__(self, name): def tearDown(self):
def wrapper(*args, **kwargs): services.redis_client = self.original_redis_client
self.operations.append((name, args, kwargs))
return self
return wrapper def test_add_publishes_notification_and_unread_count(self):
with self.settings(NOTIFICATIONS_ENABLED=True):
notification = RedisNotificationStore.add(
"user-1",
{
"title": "Build finished",
"message": "Your deploy completed.",
"level": "success",
},
)
def execute(self): self.assertEqual(notification["title"], "Build finished")
results = [] self.assertEqual(notification["message"], "Your deploy completed.")
for name, args, kwargs in self.operations: self.assertEqual(notification["level"], "success")
results.append(getattr(self.client, name)(*args, **kwargs)) self.assertEqual(len(self.fake_redis.published), 2)
self.operations.clear()
return results
channel, payload = self.fake_redis.published[0]
class FakePubSub: self.assertEqual(
def __init__(self): channel,
self.channels = [] f"{settings.NOTIFICATION_REDIS_CHANNEL_PREFIX}:user-1",
self.messages = []
self.closed = False
def subscribe(self, channel):
self.channels.append(channel)
def unsubscribe(self, channel):
if channel in self.channels:
self.channels.remove(channel)
def get_message(self, timeout=1.0):
if self.messages:
return self.messages.pop(0)
return None
def close(self):
self.closed = True
class FakeRedis:
def __init__(self):
self.sorted_sets = defaultdict(dict)
self.hashes = defaultdict(dict)
self.sets = defaultdict(set)
self.published = []
self.pubsub_instance = FakePubSub()
def pipeline(self):
return FakePipeline(self)
def zadd(self, key, mapping):
self.sorted_sets[key].update(mapping)
return len(mapping)
def hset(self, key, field, value):
self.hashes[key][field] = value
return 1
def sadd(self, key, *members):
before = len(self.sets[key])
self.sets[key].update(members)
return len(self.sets[key]) - before
def zrevrange(self, key, start, stop):
items = sorted(
self.sorted_sets[key].items(),
key=lambda item: (item[1], item[0]),
reverse=True,
) )
if stop == -1: self.assertEqual(payload["event"], "notification")
return [member for member, _ in items[start:]] self.assertEqual(payload["data"]["notification"]["id"], notification["id"])
return [member for member, _ in items[start : stop + 1]] self.assertEqual(payload["data"]["unread_count"], 1)
def hget(self, key, field): def test_mark_seen_and_mark_all_seen_publish_sync_events(self):
return self.hashes[key].get(field) with self.settings(NOTIFICATIONS_ENABLED=True):
first = RedisNotificationStore.add("user-2", {"title": "First"})
RedisNotificationStore.add("user-2", {"title": "Second"})
self.fake_redis.published.clear()
def zrem(self, key, *members): payload = RedisNotificationStore.mark_seen("user-2", first["id"])
removed = 0
for member in members:
if member in self.sorted_sets[key]:
del self.sorted_sets[key][member]
removed += 1
return removed
def hdel(self, key, *fields): self.assertEqual(payload["notification_id"], first["id"])
removed = 0 self.assertFalse(payload["deleted"])
for field in fields: self.assertTrue(payload["notification"]["is_seen"])
if field in self.hashes[key]: self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_seen")
del self.hashes[key][field]
removed += 1
return removed
def smembers(self, key): self.fake_redis.published.clear()
return set(self.sets[key]) updated = RedisNotificationStore.mark_all_seen("user-2")
def srem(self, key, member): self.assertEqual(updated, 2)
if member in self.sets[key]: self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_mark_all_read")
self.sets[key].remove(member) self.assertEqual(self.fake_redis.published[1][1]["event"], "unread_count")
return 1 self.assertEqual(self.fake_redis.published[1][1]["data"]["unread_count"], 0)
return 0
def zrangebyscore(self, key, min_score, max_score): def test_list_returns_total_count_and_filtered_notifications(self):
lower = float("-inf") if min_score == "-inf" else float(min_score) RedisNotificationStore.add("user-3", {"title": "General", "type": "general"})
upper = float(max_score) RedisNotificationStore.add("user-3", {"title": "Billing", "type": "billing"})
return [ RedisNotificationStore.add("user-3", {"title": "General 2", "type": "general"})
member
for member, score in self.sorted_sets[key].items()
if lower <= score <= upper
]
def zcard(self, key): notifications, total_count = RedisNotificationStore.list(
return len(self.sorted_sets[key]) "user-3",
limit=1,
offset=0,
type_filter="general",
)
def publish(self, channel, message): self.assertEqual(total_count, 2)
self.published.append((channel, json.loads(message))) self.assertEqual(len(notifications), 1)
return 1 self.assertEqual(notifications[0]["type"], "general")
def pubsub(self, ignore_subscribe_messages=True):
return self.pubsub_instance
@pytest.fixture()
def fake_redis(monkeypatch):
redis = FakeRedis()
monkeypatch.setattr(services, "redis_client", redis)
return redis
def test_add_publishes_notification_and_unread_count(fake_redis, settings):
settings.NOTIFICATIONS_ENABLED = True
notification = RedisNotificationStore.add(
"user-1",
{
"title": "Build finished",
"message": "Your deploy completed.",
"level": "success",
},
)
assert notification["title"] == "Build finished"
assert notification["message"] == "Your deploy completed."
assert notification["level"] == "success"
assert len(fake_redis.published) == 2
channel, payload = fake_redis.published[0]
assert channel == f"{settings.NOTIFICATION_REDIS_CHANNEL_PREFIX}:user-1"
assert payload["event"] == "notification"
assert payload["data"]["notification"]["id"] == notification["id"]
assert payload["data"]["unread_count"] == 1
def test_mark_seen_and_mark_all_seen_publish_sync_events(fake_redis, settings):
settings.NOTIFICATIONS_ENABLED = True
first = RedisNotificationStore.add("user-2", {"title": "First"})
second = RedisNotificationStore.add("user-2", {"title": "Second"})
fake_redis.published.clear()
payload = RedisNotificationStore.mark_seen("user-2", first["id"])
assert payload["notification_id"] == first["id"]
assert payload["deleted"] is False
assert payload["notification"]["is_seen"] is True
assert fake_redis.published[0][1]["event"] == "notification_seen"
fake_redis.published.clear()
updated = RedisNotificationStore.mark_all_seen("user-2")
assert updated == 2
assert fake_redis.published[0][1]["event"] == "notification_mark_all_read"
assert fake_redis.published[1][1]["event"] == "unread_count"
assert fake_redis.published[1][1]["data"]["unread_count"] == 0
def test_list_returns_total_count_and_filtered_notifications(fake_redis):
RedisNotificationStore.add("user-3", {"title": "General", "type": "general"})
RedisNotificationStore.add("user-3", {"title": "Billing", "type": "billing"})
RedisNotificationStore.add("user-3", {"title": "General 2", "type": "general"})
notifications, total_count = RedisNotificationStore.list(
"user-3",
limit=1,
offset=0,
type_filter="general",
)
assert total_count == 2
assert len(notifications) == 1
assert notifications[0]["type"] == "general"

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 json
import time import time
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch
import pytest from django.test import override_settings
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APITestCase
from apps.notifications.api import views from apps.notifications.api import views
from apps.notifications.services import store as services from apps.notifications.services import store as services
from apps.notifications.services import RedisNotificationStore from apps.notifications.services import RedisNotificationStore
from apps.notifications.tests.test_services import FakePubSub, FakeRedis from apps.notifications.tests.fakes import FakePubSub, FakeRedis
from apps.users.models import User from apps.users.models import User
@pytest.fixture() class NotificationViewTests(APITestCase):
def fake_redis(monkeypatch): @classmethod
redis = FakeRedis() def setUpTestData(cls):
monkeypatch.setattr(services, "redis_client", redis) cls.user = User.objects.create_user(mobile="09121111111", password="secret123")
return redis cls.second_user = User.objects.create_user(
mobile="09122222222",
password="secret123",
)
def setUp(self):
self.fake_redis = FakeRedis()
self.original_redis_client = services.redis_client
services.redis_client = self.fake_redis
@pytest.fixture() def tearDown(self):
def user(db): services.redis_client = self.original_redis_client
return User.objects.create_user(mobile="09121111111", password="secret123")
@staticmethod
def _read_sse_chunks(response, count):
iterator = iter(response.streaming_content)
chunks = []
for _ in range(count):
chunk = next(iterator)
if isinstance(chunk, bytes):
chunk = chunk.decode("utf-8")
chunks.append(chunk)
response.close()
return chunks
@pytest.fixture() @staticmethod
def second_user(db): def _parse_sse_data(chunk):
return User.objects.create_user(mobile="09122222222", password="secret123") for line in chunk.splitlines():
if line.startswith("data: "):
return json.loads(line.removeprefix("data: "))
raise AssertionError("SSE payload did not include data")
def test_stream_token_endpoint_returns_short_lived_token(self):
self.client.force_authenticate(user=self.user)
def _read_sse_chunks(response, count): response = self.client.post("/api/notifications/stream-token/")
iterator = iter(response.streaming_content)
chunks = []
for _ in range(count):
chunk = next(iterator)
if isinstance(chunk, bytes):
chunk = chunk.decode("utf-8")
chunks.append(chunk)
response.close()
return chunks
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data["token"])
self.assertGreater(response.data["expires_in"], 0)
def _parse_sse_data(chunk: str) -> dict: def test_stream_endpoint_rejects_missing_and_expired_token(self):
for line in chunk.splitlines(): missing = self.client.get("/api/notifications/stream/")
if line.startswith("data: "): self.assertEqual(missing.status_code, 401)
return json.loads(line.removeprefix("data: "))
raise AssertionError("SSE payload did not include data")
with override_settings(NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS=1):
token = views._issue_stream_token_for_user(str(self.user.id))
time.sleep(1.1)
expired = self.client.get(f"/api/notifications/stream/?token={token}")
def test_stream_token_endpoint_returns_short_lived_token(user): self.assertEqual(expired.status_code, 401)
client = APIClient()
client.force_authenticate(user=user)
response = client.post("/api/notifications/stream-token/") def test_stream_endpoint_sends_only_current_users_notifications(self):
RedisNotificationStore.add(str(self.user.id), {"title": "For current user"})
RedisNotificationStore.add(str(self.second_user.id), {"title": "For another user"})
pubsub = FakePubSub()
assert response.status_code == 200 with patch.object(
assert response.data["token"] RedisNotificationStore,
assert response.data["expires_in"] > 0 "get_pubsub",
classmethod(lambda cls: pubsub),
):
token = views._issue_stream_token_for_user(str(self.user.id))
response = self.client.get(
f"/api/notifications/stream/?token={token}",
HTTP_ACCEPT="text/event-stream",
)
retry_line, connected_chunk = self._read_sse_chunks(response, 2)
self.assertEqual(response.status_code, 200)
self.assertTrue(retry_line.startswith("retry:"))
connected = self._parse_sse_data(connected_chunk)
self.assertEqual(connected["unread_count"], 1)
self.assertEqual(
[item["title"] for item in connected["notifications"]],
["For current user"],
)
def test_stream_endpoint_rejects_missing_and_expired_token(user, settings): def test_stream_endpoint_emits_heartbeat(self):
client = APIClient() pubsub = FakePubSub()
first_now = timezone.now()
tick_values = iter(
[
first_now,
first_now,
first_now + timedelta(seconds=2),
first_now + timedelta(seconds=2),
first_now + timedelta(seconds=2),
first_now + timedelta(seconds=2),
]
)
last_tick = first_now + timedelta(seconds=2)
missing = client.get("/api/notifications/stream/") def fake_now():
assert missing.status_code == 401 return next(tick_values, last_tick)
settings.NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS = 1 with override_settings(NOTIFICATION_SSE_HEARTBEAT_SECONDS=1):
token = views._issue_stream_token_for_user(str(user.id)) with patch.object(
time.sleep(1.1) RedisNotificationStore,
"get_pubsub",
classmethod(lambda cls: pubsub),
):
with patch.object(views.timezone, "now", side_effect=fake_now):
view = views.NotificationStreamView()
stream = view._build_stream(str(self.user.id))
chunks = [next(stream) for _ in range(4)]
stream.close()
expired = client.get(f"/api/notifications/stream/?token={token}") self.assertIn("event: ping", chunks[3])
assert expired.status_code == 401
def test_notification_list_and_seen_endpoints_work(self):
notification = RedisNotificationStore.add(
str(self.user.id),
{"title": "Deploy succeeded", "type": "deploy"},
)
self.client.force_authenticate(user=self.user)
def test_stream_endpoint_sends_only_current_users_notifications( list_response = self.client.get("/api/notifications/list/?type=deploy")
fake_redis, user, second_user, monkeypatch self.assertEqual(list_response.status_code, 200)
): self.assertEqual(list_response.data["count"], 1)
RedisNotificationStore.add(str(user.id), {"title": "For current user"}) self.assertEqual(list_response.data["unread_count"], 1)
RedisNotificationStore.add(str(second_user.id), {"title": "For another user"}) self.assertEqual(
pubsub = FakePubSub() list_response.data["notifications"][0]["title"],
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub)) "Deploy succeeded",
token = views._issue_stream_token_for_user(str(user.id)) )
client = APIClient() seen_response = self.client.post(
response = client.get( "/api/notifications/seen/",
f"/api/notifications/stream/?token={token}", {"id": notification["id"]},
HTTP_ACCEPT="text/event-stream", format="json",
) )
retry_line, connected_chunk = _read_sse_chunks(response, 2) self.assertEqual(seen_response.status_code, 200)
self.assertTrue(seen_response.data["marked_read"])
self.assertTrue(seen_response.data["notification"]["is_seen"])
assert response.status_code == 200 def test_notification_delete_endpoint_removes_notification(self):
assert retry_line.startswith("retry:") notification = RedisNotificationStore.add(
connected = _parse_sse_data(connected_chunk) str(self.user.id),
assert connected["unread_count"] == 1 {"title": "Delete me", "type": "deploy"},
assert [item["title"] for item in connected["notifications"]] == ["For current user"] )
self.client.force_authenticate(user=self.user)
response = self.client.delete(f"/api/notifications/{notification['id']}/")
def test_stream_endpoint_emits_heartbeat(fake_redis, user, settings, monkeypatch): self.assertEqual(response.status_code, 200)
pubsub = FakePubSub() self.assertTrue(response.data["deleted"])
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub)) self.assertEqual(response.data["notification_id"], notification["id"])
settings.NOTIFICATION_SSE_HEARTBEAT_SECONDS = 1 self.assertIsNone(RedisNotificationStore.get(str(self.user.id), notification["id"]))
first_now = timezone.now()
tick_values = iter(
[
first_now,
first_now,
first_now + timedelta(seconds=2),
first_now + timedelta(seconds=2),
first_now + timedelta(seconds=2),
first_now + timedelta(seconds=2),
]
)
last_tick = first_now + timedelta(seconds=2)
def fake_now():
return next(tick_values, last_tick)
monkeypatch.setattr(views.timezone, "now", fake_now)
view = views.NotificationStreamView()
stream = view._build_stream(str(user.id))
chunks = [next(stream) for _ in range(4)]
stream.close()
assert "event: ping" in chunks[3]
def test_notification_list_and_seen_endpoints_work(fake_redis, user):
notification = RedisNotificationStore.add(
str(user.id),
{"title": "Deploy succeeded", "type": "deploy"},
)
client = APIClient()
client.force_authenticate(user=user)
list_response = client.get("/api/notifications/list/?type=deploy")
assert list_response.status_code == 200
assert list_response.data["count"] == 1
assert list_response.data["unread_count"] == 1
assert list_response.data["notifications"][0]["title"] == "Deploy succeeded"
seen_response = client.post("/api/notifications/seen/", {"id": notification["id"]}, format="json")
assert seen_response.status_code == 200
assert seen_response.data["marked_read"] is True
assert seen_response.data["notification"]["is_seen"] is True
def test_notification_delete_endpoint_removes_notification(fake_redis, user):
notification = RedisNotificationStore.add(
str(user.id),
{"title": "Delete me", "type": "deploy"},
)
client = APIClient()
client.force_authenticate(user=user)
response = client.delete(f"/api/notifications/{notification['id']}/")
assert response.status_code == 200
assert response.data["deleted"] is True
assert response.data["notification_id"] == notification["id"]
assert RedisNotificationStore.get(str(user.id), notification["id"]) is None

View File

@@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from core.admins.base import BaseAdmin from core.admins.base import BaseAdmin, SoftDeleteListFilter
from apps.projects.models import Project from apps.projects.models import Project
@@ -16,6 +16,7 @@ class ProjectAdmin(BaseAdmin):
) )
list_filter = ( list_filter = (
SoftDeleteListFilter,
"workspace", "workspace",
"is_archived", "is_archived",
"is_deleted", "is_deleted",

View File

@@ -1,6 +1,22 @@
from decimal import Decimal
from rest_framework import serializers from rest_framework import serializers
from core.serializers.base import BaseModelSerializer from core.serializers.base import BaseModelSerializer
from apps.projects.models import Project 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 ProjectSerializer(BaseModelSerializer):
@@ -11,6 +27,7 @@ class ProjectSerializer(BaseModelSerializer):
"name", "name",
"client", "client",
"description", "description",
"thumbnail",
"is_archived", "is_archived",
"color", "color",
) )
@@ -18,10 +35,23 @@ class ProjectSerializer(BaseModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
representation = super().to_representation(instance) representation = super().to_representation(instance)
request = self.context.get("request")
if instance.thumbnail:
thumbnail_url = instance.thumbnail.url
representation["thumbnail"] = request.build_absolute_uri(thumbnail_url) if request else thumbnail_url
else:
representation["thumbnail"] = None
if instance.client: if instance.client:
representation['client'] = { representation['client'] = {
'id': instance.client.id, 'id': instance.client.id,
'name': instance.client.name 'name': instance.client.name,
'thumbnail': (
request.build_absolute_uri(instance.client.thumbnail.url)
if request and instance.client.thumbnail
else instance.client.thumbnail.url
if instance.client.thumbnail
else None
),
} }
return representation return representation
@@ -31,12 +61,55 @@ class ProjectCreateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255) name = serializers.CharField(max_length=255)
client = serializers.UUIDField(required=False, allow_null=True) client = serializers.UUIDField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True, default="") description = serializers.CharField(required=False, allow_blank=True, default="")
thumbnail = serializers.ImageField(required=False, allow_null=True)
color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="") color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="")
def validate_thumbnail(self, value):
return validate_thumbnail(value)
class ProjectUpdateSerializer(serializers.Serializer): class ProjectUpdateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255, required=False) name = serializers.CharField(max_length=255, required=False)
client = serializers.UUIDField(required=False, allow_null=True) client = serializers.UUIDField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True) description = serializers.CharField(required=False, allow_blank=True)
thumbnail = serializers.ImageField(required=False, allow_null=True)
clear_thumbnail = serializers.BooleanField(required=False, default=False)
color = serializers.CharField(max_length=7, required=False, allow_blank=True) color = serializers.CharField(max_length=7, required=False, allow_blank=True)
is_archived = serializers.BooleanField(required=False) is_archived = serializers.BooleanField(required=False)
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

@@ -15,8 +15,21 @@ from apps.clients.models import Client
from apps.projects.models import Project from apps.projects.models import Project
from apps.projects.api.serializers import ( from apps.projects.api.serializers import (
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer, ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
ProjectAccessMutationSerializer, ProjectAccessQuerySerializer,
ProjectAccessRateMutationSerializer,
) )
from apps.projects.api.permissions import IsProjectMember, IsProjectManager from apps.projects.api.permissions import IsProjectMember, IsProjectManager
from apps.projects.services.access import (
build_project_access_item,
build_project_access_items,
ensure_workspace_project_access,
filter_projects_for_user,
get_project_access_target_membership,
grant_project_accesses,
revoke_project_accesses,
user_has_project_access,
)
from apps.projects.services.rates import get_current_project_user_rate, remove_project_user_rate, upsert_project_user_rate
from apps.projects.services.projects import ( from apps.projects.services.projects import (
create_project, create_project,
update_project, update_project,
@@ -67,16 +80,18 @@ class ProjectViewSet(ModelViewSet):
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated: if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
return Project.objects.none() return Project.objects.none()
queryset = Project.objects.filter( queryset = filter_projects_for_user(
workspace__memberships__user=self.request.user, self.request.user,
workspace__memberships__is_active=True, Project.objects.filter(is_deleted=False),
is_deleted=False )
).distinct()
client_ids = [client_id for client_id in self.request.query_params.getlist("clients") if client_id] client_ids = [client_id for client_id in self.request.query_params.getlist("clients") if client_id]
if client_ids: if client_ids:
queryset = queryset.filter(client_id__in=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 return queryset
def get_serializer_class(self): def get_serializer_class(self):
@@ -111,10 +126,11 @@ class ProjectViewSet(ModelViewSet):
name=serializer.validated_data["name"], name=serializer.validated_data["name"],
client=client, client=client,
description=serializer.validated_data.get("description", ""), description=serializer.validated_data.get("description", ""),
color=serializer.validated_data.get("color", "") color=serializer.validated_data.get("color", ""),
thumbnail=serializer.validated_data.get("thumbnail"),
) )
output_serializer = ProjectSerializer(project) output_serializer = ProjectSerializer(project, context={"request": request})
return Response(output_serializer.data, status=status.HTTP_201_CREATED) return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
@@ -132,7 +148,7 @@ class ProjectViewSet(ModelViewSet):
**serializer.validated_data **serializer.validated_data
) )
output_serializer = ProjectSerializer(updated_project) output_serializer = ProjectSerializer(updated_project, context={"request": request})
return Response(output_serializer.data, status=status.HTTP_200_OK) return Response(output_serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
@@ -157,5 +173,120 @@ class ProjectViewSet(ModelViewSet):
project = self.get_object() project = self.get_object()
updated_project = toggle_project_archive(project) updated_project = toggle_project_archive(project)
output_serializer = ProjectSerializer(updated_project) output_serializer = ProjectSerializer(updated_project, context={"request": request})
return Response(output_serializer.data, status=status.HTTP_200_OK) return Response(output_serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_path="access")
def access(self, request):
serializer = ProjectAccessQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
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,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

@@ -28,6 +28,8 @@ class Project(BaseModel):
description = models.TextField(blank=True) description = models.TextField(blank=True)
thumbnail = models.ImageField(upload_to="profile/projects/", blank=True, null=True)
is_archived = models.BooleanField(default=False) is_archived = models.BooleanField(default=False)
color = models.CharField(max_length=7, blank=True) color = models.CharField(max_length=7, blank=True)
@@ -37,6 +39,7 @@ class Project(BaseModel):
ordering = ("-updated_at", "-created_at") ordering = ("-updated_at", "-created_at")
indexes = [ indexes = [
models.Index(fields=["workspace"], name="project_workspace_idx"), models.Index(fields=["workspace"], name="project_workspace_idx"),
models.Index(fields=["workspace", "is_archived", "updated_at"], name="project_ws_arch_upd_idx"),
] ]
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
@@ -129,3 +132,31 @@ class ProjectUserRate(BaseModel):
models.Index(fields=["project"], name="pur_project_idx"), models.Index(fields=["project"], name="pur_project_idx"),
models.Index(fields=["user"], name="pur_user_idx"), models.Index(fields=["user"], name="pur_user_idx"),
] ]
class ProjectAccess(BaseModel):
project = models.ForeignKey(
Project,
on_delete=models.CASCADE,
related_name="access_memberships",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="project_accesses",
)
class Meta:
db_table = "project_access"
ordering = ("-created_at",)
constraints = [
models.UniqueConstraint(
fields=["project", "user"],
name="unique_project_access",
condition=models.Q(is_deleted=False),
)
]
indexes = [
models.Index(fields=["project"], name="project_access_project_idx"),
models.Index(fields=["user"], name="project_access_user_idx"),
]

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

@@ -8,7 +8,7 @@ from apps.workspaces.models import WorkspaceMembership
@transaction.atomic @transaction.atomic
def create_project(user, workspace, name, client=None, description="", color=""): def create_project(user, workspace, name, client=None, description="", color="", thumbnail=None):
""" """
Creates a new workspace-shared project. Creates a new workspace-shared project.
""" """
@@ -30,6 +30,7 @@ def create_project(user, workspace, name, client=None, description="", color="")
name=name, name=name,
client=client, client=client,
description=description, description=description,
thumbnail=thumbnail,
color=color, color=color,
created_by=user, created_by=user,
updated_by=user, updated_by=user,
@@ -49,9 +50,17 @@ def update_project(project, **kwargs):
if Project.objects.filter(workspace=project.workspace, name=kwargs["name"], is_deleted=False).exists(): if Project.objects.filter(workspace=project.workspace, name=kwargs["name"], is_deleted=False).exists():
raise ValidationError({"name": "A project with this name already exists in the workspace."}) raise ValidationError({"name": "A project with this name already exists in the workspace."})
client_id = kwargs.pop("client") if "client" in kwargs:
client = get_object_or_404(Client, id=client_id, is_deleted=False) if client_id else None client_id = kwargs.pop("client")
kwargs["client"] = 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(): for field, value in kwargs.items():
if hasattr(project, field) and getattr(project, field) != value: if hasattr(project, field) and getattr(project, field) != value:
@@ -61,6 +70,10 @@ def update_project(project, **kwargs):
if update_fields: if update_fields:
update_fields.append("updated_at") update_fields.append("updated_at")
project.save(update_fields=update_fields) project.save(update_fields=update_fields)
if old_thumbnail_name and project.thumbnail and project.thumbnail.name != old_thumbnail_name:
storage = project.thumbnail.storage
if storage.exists(old_thumbnail_name):
storage.delete(old_thumbnail_name)
return project return project

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

@@ -1,75 +1,219 @@
import pytest from decimal import Decimal
from rest_framework.test import APIClient
from rest_framework.test import APITestCase
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import Project from apps.projects.models import Project, ProjectAccess, ProjectUserRate
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
@pytest.fixture() class ProjectViewTests(APITestCase):
def api_client(): @classmethod
return APIClient() def setUpTestData(cls):
cls.owner = User.objects.create_user(
mobile="09121110001",
password="secret123",
first_name="Owner",
)
cls.workspace = Workspace.objects.create(name="Projects", owner=cls.owner)
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)
@pytest.fixture() response = self.client.get(
def owner(db): "/api/projects/",
return User.objects.create_user(mobile="09121110001", password="secret123", first_name="Owner") [
("workspace", str(self.workspace.id)),
("clients", str(self.first_client.id)),
("clients", str(self.second_client.id)),
],
)
self.assertEqual(response.status_code, 200)
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)},
)
@pytest.fixture() def test_project_access_list_and_mutations_require_explicit_member_access(self):
def workspace(owner): self.client.force_authenticate(user=self.owner)
return Workspace.objects.create(name="Projects", owner=owner)
access_response = self.client.get(
"/api/projects/access/",
{"workspace": str(self.workspace.id), "user": str(self.member.id)},
)
@pytest.fixture() self.assertEqual(access_response.status_code, 200)
def member(db, workspace): items = access_response.data["items"]
user = User.objects.create_user(mobile="09121110002", password="secret123", first_name="Member") gamma_item = next(item for item in items if item["id"] == str(self.third_project.id))
WorkspaceMembership.objects.create( self.assertFalse(gamma_item["has_access"])
workspace=workspace, alpha_item = next(item for item in items if item["id"] == str(self.first_project.id))
user=user, self.assertEqual(alpha_item["workspace_rate"]["hourly_rate"], "25.00")
role=WorkspaceMembership.Role.MEMBER, self.assertIsNone(alpha_item["project_rate"])
is_active=True,
)
return user
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)
@pytest.fixture() access_response = self.client.get(
def clients(workspace): "/api/projects/access/",
first = Client.objects.create(workspace=workspace, name="Acme") {"workspace": str(self.workspace.id), "user": str(self.member.id)},
second = Client.objects.create(workspace=workspace, name="Globex") )
third = Client.objects.create(workspace=workspace, name="Initech") gamma_item = next(item for item in access_response.data["items"] if item["id"] == str(self.third_project.id))
return first, second, third 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())
@pytest.fixture() def test_project_access_rate_endpoint_saves_override_and_keeps_it_dormant_after_revoke(self):
def projects(workspace, clients): self.client.force_authenticate(user=self.owner)
first, second, third = clients
return [
Project.objects.create(workspace=workspace, client=first, name="Alpha"),
Project.objects.create(workspace=workspace, client=second, name="Beta"),
Project.objects.create(workspace=workspace, client=third, name="Gamma"),
]
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",
)
def test_project_list_supports_multi_client_filter(api_client, member, workspace, clients, projects): self.assertEqual(save_response.status_code, 200)
api_client.force_authenticate(user=member) self.assertFalse(save_response.data["removed"])
first, second, _ = clients 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()
)
response = api_client.get( revoke_response = self.client.post(
"/api/projects/", "/api/projects/access/revoke/",
[ {
("workspace", str(workspace.id)), "workspace": str(self.workspace.id),
("clients", str(first.id)), "user": str(self.member.id),
("clients", str(second.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()
)
assert response.status_code == 200 access_response = self.client.get(
items = ( "/api/projects/access/",
response.data {"workspace": str(self.workspace.id), "user": str(self.member.id)},
if isinstance(response.data, list) )
else response.data.get("results") or response.data.get("items", []) 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))
result_ids = {str(item["client"]["id"]) for item in items} self.assertFalse(alpha_item["has_access"])
assert result_ids == {str(first.id), str(second.id)} 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())

View File

@@ -6,6 +6,7 @@ from apps.reports.api.views import (
ReportDayDetailsView, ReportDayDetailsView,
ReportExportJobViewSet, ReportExportJobViewSet,
ReportTableView, ReportTableView,
ReportUserSummaryView,
) )
router = DefaultRouter() router = DefaultRouter()
@@ -15,6 +16,6 @@ urlpatterns = [
path("chart/", ReportChartView.as_view(), name="report-chart"), path("chart/", ReportChartView.as_view(), name="report-chart"),
path("table/", ReportTableView.as_view(), name="report-table"), path("table/", ReportTableView.as_view(), name="report-table"),
path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"), path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"),
path("user-summary/", ReportUserSummaryView.as_view(), name="report-user-summary"),
path("", include(router.urls)), path("", include(router.urls)),
] ]

View File

@@ -20,9 +20,14 @@ from apps.reports.services import (
build_chart_report, build_chart_report,
build_day_details_report, build_day_details_report,
build_table_report, build_table_report,
build_user_summary_report,
load_report_filters, load_report_filters,
) )
from apps.reports.tasks import generate_report_export_task from apps.reports.tasks import generate_report_export_task
from core.services.cache import CACHE_NAMESPACE_REPORTS, get_or_set_cache_payload
REPORT_CACHE_TTL_SECONDS = 90
class ReportChartView(APIView): class ReportChartView(APIView):
@@ -30,7 +35,17 @@ class ReportChartView(APIView):
@extend_schema(responses=dict) @extend_schema(responses=dict)
def get(self, request): def get(self, request):
return Response(build_chart_report(request.user, request.query_params)) workspace_id = request.query_params.get("workspace")
payload = get_or_set_cache_payload(
CACHE_NAMESPACE_REPORTS,
ttl_seconds=REPORT_CACHE_TTL_SECONDS,
builder=lambda: build_chart_report(request.user, request.query_params),
resource="chart",
user_id=request.user.id,
workspace_id=workspace_id,
params=request.query_params,
)
return Response(payload)
class ReportTableView(APIView): class ReportTableView(APIView):
@@ -38,7 +53,17 @@ class ReportTableView(APIView):
@extend_schema(responses=dict) @extend_schema(responses=dict)
def get(self, request): def get(self, request):
return Response(build_table_report(request.user, request.query_params)) workspace_id = request.query_params.get("workspace")
payload = get_or_set_cache_payload(
CACHE_NAMESPACE_REPORTS,
ttl_seconds=REPORT_CACHE_TTL_SECONDS,
builder=lambda: build_table_report(request.user, request.query_params),
resource="table",
user_id=request.user.id,
workspace_id=workspace_id,
params=request.query_params,
)
return Response(payload)
class ReportDayDetailsView(APIView): class ReportDayDetailsView(APIView):
@@ -46,7 +71,35 @@ class ReportDayDetailsView(APIView):
@extend_schema(responses=dict) @extend_schema(responses=dict)
def get(self, request): def get(self, request):
return Response(build_day_details_report(request.user, request.query_params)) workspace_id = request.query_params.get("workspace")
payload = get_or_set_cache_payload(
CACHE_NAMESPACE_REPORTS,
ttl_seconds=REPORT_CACHE_TTL_SECONDS,
builder=lambda: build_day_details_report(request.user, request.query_params),
resource="day-details",
user_id=request.user.id,
workspace_id=workspace_id,
params=request.query_params,
)
return Response(payload)
class ReportUserSummaryView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(responses=dict)
def get(self, request):
workspace_id = request.query_params.get("workspace")
payload = get_or_set_cache_payload(
CACHE_NAMESPACE_REPORTS,
ttl_seconds=REPORT_CACHE_TTL_SECONDS,
builder=lambda: build_user_summary_report(request.user, request.query_params),
resource="user-summary",
user_id=request.user.id,
workspace_id=workspace_id,
params=request.query_params,
)
return Response(payload)
class ReportExportJobViewSet( class ReportExportJobViewSet(

View File

@@ -2,6 +2,7 @@ from apps.reports.services.aggregation import (
build_chart_report, build_chart_report,
build_day_details_report, build_day_details_report,
build_table_report, build_table_report,
build_user_summary_report,
build_user_scoped_table_reports, build_user_scoped_table_reports,
load_report_filters, load_report_filters,
) )
@@ -10,6 +11,7 @@ __all__ = [
"load_report_filters", "load_report_filters",
"build_chart_report", "build_chart_report",
"build_table_report", "build_table_report",
"build_user_summary_report",
"build_user_scoped_table_reports", "build_user_scoped_table_reports",
"build_day_details_report", "build_day_details_report",
] ]

View File

@@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from collections.abc import Iterable
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from decimal import Decimal from decimal import ROUND_DOWN, Decimal
from typing import Iterable
import jdatetime import jdatetime
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@@ -15,9 +15,10 @@ from rest_framework import serializers
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import Project from apps.projects.models import Project
from apps.projects.services.access import user_has_project_access
from apps.tags.models import Tag from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry from apps.time_entries.models import TimeEntry
from apps.workspaces.models import Workspace from apps.workspaces.models import HourlyRateHistory, Workspace
from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability
User = get_user_model() User = get_user_model()
@@ -38,6 +39,25 @@ ALLOWED_PERIODS = {
PERIOD_CUSTOM, PERIOD_CUSTOM,
} }
UNCATEGORIZED_IDS = {
"clients": "__uncategorized_client__",
"projects": "__uncategorized_project__",
"tags": "__uncategorized_tag__",
}
UNCATEGORIZED_LABELS = {
"en": {
"clients": "No client",
"projects": "No project",
"tags": "No tag",
},
"fa": {
"clients": "بدون مشتری",
"projects": "بدون پروژه",
"tags": "بدون تگ",
},
}
def _start_of_week(local_date: date) -> date: def _start_of_week(local_date: date) -> date:
days_since_sunday = (local_date.weekday() + 1) % 7 days_since_sunday = (local_date.weekday() + 1) % 7
@@ -90,6 +110,365 @@ def _serialize_rate(amount: Decimal | None, currency: str | None) -> dict | None
} }
def _serialize_distinct_rates_from_rows(rate_rows: list[dict]) -> list[dict]:
unique_rates: set[tuple[str, str]] = set()
for row in rate_rows:
unique_rates.add((row["amount"], row["currency"]))
return [
{"amount": amount, "currency": currency}
for amount, currency in sorted(unique_rates, key=lambda item: (item[1], Decimal(item[0])))
]
def _serialize_rate_history_rows(*, user, workspace: Workspace, from_date: date, to_date: date) -> list[dict]:
current_timezone = timezone.get_current_timezone()
period_start = timezone.make_aware(datetime.combine(from_date, time.min), current_timezone)
period_end = timezone.make_aware(datetime.combine(to_date + timedelta(days=1), time.min), current_timezone)
rows = list(
HourlyRateHistory.objects.filter(
workspace=workspace,
user=user,
is_deleted=False,
effective_from__lt=period_end,
)
.select_related("project")
.order_by("scope", "project_id", "effective_from", "created_at")
)
grouped: dict[tuple[str, str | None], list[HourlyRateHistory]] = defaultdict(list)
for row in rows:
grouped[(row.scope, str(row.project_id) if row.project_id else None)].append(row)
serialized: list[dict] = []
for (_scope, _project_id), history_rows in grouped.items():
selected_indexes = {
index for index, row in enumerate(history_rows) if row.effective_from >= period_start
}
previous_indexes = [
index for index, row in enumerate(history_rows) if row.effective_from < period_start
]
if previous_indexes:
selected_indexes.add(previous_indexes[-1])
for index in sorted(selected_indexes):
row = history_rows[index]
next_row = history_rows[index + 1] if index + 1 < len(history_rows) else None
if next_row and next_row.effective_from < period_start:
continue
from_day = max(_localize_datetime(row.effective_from).date(), from_date)
to_day = min(_localize_datetime(next_row.effective_from).date(), to_date) if next_row else None
serialized.append(
{
"amount": f"{Decimal(row.hourly_rate).quantize(Decimal('0.01'))}",
"currency": row.currency or "USD",
"from_date": from_day.isoformat(),
"to_date": to_day.isoformat() if to_day else None,
"scope": row.scope,
"project_name": row.project.name if row.project else None,
"is_current": next_row is None,
}
)
return sorted(
serialized,
key=lambda item: (
item["from_date"],
item["scope"],
item.get("project_name") or "",
Decimal(item["amount"]),
),
)
def _uncategorized_label(kind: str, language: str) -> str:
if language == "fa":
return {
"clients": "بدون مشتری",
"projects": "بدون پروژه",
"tags": "بدون تگ",
}[kind]
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
return UNCATEGORIZED_LABELS[resolved_language][kind]
def _share_bucket(bucket_id: str, name: str) -> dict:
return {
"id": bucket_id,
"name": name,
"seconds": Decimal("0"),
"income": _money_map(),
}
def _entry_income_payload(entry: TimeEntry) -> tuple[str, Decimal] | None:
if not entry.is_billable or not entry.hourly_rate:
return None
duration_seconds = get_entry_duration_seconds(entry)
if duration_seconds <= 0:
return None
hourly_rate = Decimal(entry.hourly_rate)
income = (hourly_rate * Decimal(duration_seconds) / Decimal(3600)).quantize(Decimal("0.01"))
return entry.currency or "USD", income
def _add_money(bucket: defaultdict[str, Decimal], currency: str, amount: Decimal) -> None:
bucket[currency] += amount
def _breakdown_targets(entry: TimeEntry, kind: str, language: str) -> list[tuple[str, str]]:
if kind == "clients":
if entry.project and entry.project.client:
return [(str(entry.project.client_id), entry.project.client.name)]
return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))]
if kind == "projects":
if entry.project:
return [(str(entry.project_id), entry.project.name)]
return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))]
tags = list(entry.tags.all())
if tags:
return [(str(tag.id), tag.name) for tag in tags]
return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))]
def _accumulate_breakdown_shares(entries: list[TimeEntry], kind: str, *, language: str) -> dict[str, dict]:
shares: dict[str, dict] = {}
for entry in entries:
if not entry.is_billable:
continue
duration_seconds = get_entry_duration_seconds(entry)
if duration_seconds <= 0:
continue
targets = _breakdown_targets(entry, kind, language)
divisor = Decimal(len(targets)) if kind == "tags" and targets else Decimal("1")
income_payload = _entry_income_payload(entry)
for bucket_id, bucket_name in targets:
bucket = shares.setdefault(bucket_id, _share_bucket(bucket_id, bucket_name))
bucket["seconds"] += Decimal(duration_seconds) / divisor
if income_payload:
currency, amount = income_payload
_add_money(bucket["income"], currency, amount / divisor)
return shares
def _allocate_percentage_rows(items: list[dict], total_value: Decimal) -> list[dict]:
if total_value <= 0 or not items:
return []
working_rows: list[dict] = []
assigned_total = 0
for item in items:
value = Decimal(item["value"])
raw_percentage = (value * Decimal("100") / total_value) if value > 0 else Decimal("0")
floored_percentage = int(raw_percentage.quantize(Decimal("1"), rounding=ROUND_DOWN))
assigned_total += floored_percentage
working_rows.append(
{
"id": item["id"],
"name": item["name"],
"value": value,
"percentage": floored_percentage,
"remainder": raw_percentage - Decimal(floored_percentage),
}
)
remaining_points = max(0, 100 - assigned_total)
for row in sorted(
working_rows,
key=lambda item: (-item["remainder"], -item["value"], item["name"].lower(), item["id"]),
)[:remaining_points]:
row["percentage"] += 1
serialized = [
{
"id": row["id"],
"name": row["name"],
"percentage": str(row["percentage"]),
}
for row in working_rows
]
serialized.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower()))
return serialized
def _single_currency_amount(income_totals: list[dict]) -> tuple[str | None, Decimal] | None:
non_zero_totals: list[tuple[str, Decimal]] = []
for item in income_totals:
amount = Decimal(item["amount"])
if amount == 0:
continue
non_zero_totals.append((item["currency"], amount))
if not non_zero_totals:
return None, Decimal("0")
currencies = {currency for currency, _ in non_zero_totals}
if len(currencies) != 1:
return None
currency = non_zero_totals[0][0]
total_amount = sum((amount for _, amount in non_zero_totals), Decimal("0"))
return currency, total_amount
def _complete_percentage_rows(
rows: list[dict],
percentage_rows: list[dict],
*,
unavailable: bool = False,
) -> list[dict]:
if unavailable:
return []
existing_ids = {row["id"] for row in percentage_rows}
completed = percentage_rows + [
{"id": row["id"], "name": row["name"], "percentage": "0"}
for row in rows
if row["id"] not in existing_ids
]
completed.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower()))
return completed
def _serialize_time_percentage_rows(rows: list[dict], shares: dict[str, dict]) -> list[dict]:
items = [
{
"id": bucket["id"],
"name": bucket["name"],
"value": Decimal(bucket["seconds"]),
}
for bucket in shares.values()
]
total_seconds = sum((item["value"] for item in items), Decimal("0"))
return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_seconds))
def _serialize_income_percentage_rows(rows: list[dict], shares: dict[str, dict]) -> list[dict]:
items: list[dict] = []
currencies: set[str] = set()
for bucket in shares.values():
income_totals = _serialize_money_totals(bucket["income"])
currency_amount = _single_currency_amount(income_totals)
if currency_amount is None:
return []
currency, amount = currency_amount
if currency:
currencies.add(currency)
items.append(
{
"id": bucket["id"],
"name": bucket["name"],
"value": amount,
}
)
if len(currencies) > 1:
return []
total_income = sum((item["value"] for item in items), Decimal("0"))
if total_income <= 0:
return []
return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_income))
def _build_user_summary(
user,
entries: list[TimeEntry],
*,
workspace: Workspace,
from_date: date,
to_date: date,
language: str,
) -> dict:
summary = _summary_from_entries(entries)
rate_rows = _serialize_rate_history_rows(
user=user,
workspace=workspace,
from_date=from_date,
to_date=to_date,
)
project_rows = _build_breakdown(entries, "projects", language=language)
client_rows = _build_breakdown(entries, "clients", language=language)
tag_rows = _build_breakdown(entries, "tags", language=language)
project_shares = _accumulate_breakdown_shares(entries, "projects", language=language)
client_shares = _accumulate_breakdown_shares(entries, "clients", language=language)
tag_shares = _accumulate_breakdown_shares(entries, "tags", language=language)
return {
"user": {
"id": str(user.id),
"name": _user_display(user),
"mobile": user.mobile,
},
"hourly_rates": _serialize_distinct_rates_from_rows(rate_rows),
"rate_periods": rate_rows,
"total_seconds": summary["billable_seconds"],
"total_duration": summary["total_duration"],
"billable_seconds": summary["billable_seconds"],
"billable_duration": summary["billable_duration"],
"non_billable_seconds": summary["non_billable_seconds"],
"non_billable_duration": summary["non_billable_duration"],
"income_totals": summary["income_totals"],
"project_percentages": _serialize_time_percentage_rows(project_rows, project_shares),
"client_percentages": _serialize_time_percentage_rows(client_rows, client_shares),
"tag_percentages": _serialize_time_percentage_rows(tag_rows, tag_shares),
"project_income_percentages": _serialize_income_percentage_rows(project_rows, project_shares),
"client_income_percentages": _serialize_income_percentage_rows(client_rows, client_shares),
"tag_income_percentages": _serialize_income_percentage_rows(tag_rows, tag_shares),
}
def _build_user_summaries(entries: list[TimeEntry], *, filters: ReportFilters) -> list[dict]:
grouped: dict[str, list[TimeEntry]] = defaultdict(list)
for entry in entries:
grouped[str(entry.user_id)].append(entry)
summaries = [
_build_user_summary(
grouped_entries[0].user,
grouped_entries,
workspace=filters.workspace,
from_date=filters.from_date,
to_date=filters.to_date,
language=filters.language,
)
for grouped_entries in grouped.values()
if grouped_entries
]
summaries.sort(key=lambda item: item["user"]["name"].lower())
return summaries
def _build_overall_percentage_payload(
entries: list[TimeEntry],
*,
language: str,
rows_by_kind: dict[str, list[dict]],
) -> dict:
project_shares = _accumulate_breakdown_shares(entries, "projects", language=language)
client_shares = _accumulate_breakdown_shares(entries, "clients", language=language)
tag_shares = _accumulate_breakdown_shares(entries, "tags", language=language)
return {
"project_percentages": _serialize_time_percentage_rows(rows_by_kind["projects"], project_shares),
"client_percentages": _serialize_time_percentage_rows(rows_by_kind["clients"], client_shares),
"tag_percentages": _serialize_time_percentage_rows(rows_by_kind["tags"], tag_shares),
"project_income_percentages": _serialize_income_percentage_rows(rows_by_kind["projects"], project_shares),
"client_income_percentages": _serialize_income_percentage_rows(rows_by_kind["clients"], client_shares),
"tag_income_percentages": _serialize_income_percentage_rows(rows_by_kind["tags"], tag_shares),
}
def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry): def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry):
if not entry.is_billable or not entry.hourly_rate: if not entry.is_billable or not entry.hourly_rate:
return return
@@ -144,7 +523,13 @@ class ReportFilterSerializer(serializers.Serializer):
language = serializers.ChoiceField(choices=("en", "fa"), required=False, default="en") language = serializers.ChoiceField(choices=("en", "fa"), required=False, default="en")
def _resolve_period_bounds(period: str, from_date: date | None, to_date: date | None, *, language: str) -> tuple[date, date]: def _resolve_period_bounds(
period: str,
from_date: date | None,
to_date: date | None,
*,
language: str,
) -> tuple[date, date]:
today = timezone.localdate() today = timezone.localdate()
if language == "fa": if language == "fa":
today_jalali = jdatetime.date.fromgregorian(date=today) today_jalali = jdatetime.date.fromgregorian(date=today)
@@ -212,7 +597,11 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
"user": raw_data.get("user"), "user": raw_data.get("user"),
"client": raw_data.get("client"), "client": raw_data.get("client"),
"project": raw_data.get("project"), "project": raw_data.get("project"),
"tags": raw_data.get("tags") or raw_data.getlist("tags") if hasattr(raw_data, "getlist") else raw_data.get("tags"), "tags": (
raw_data.get("tags") or raw_data.getlist("tags")
if hasattr(raw_data, "getlist")
else raw_data.get("tags")
),
"language": raw_data.get("language", "en"), "language": raw_data.get("language", "en"),
} }
if normalized["tags"] and not isinstance(normalized["tags"], list): if normalized["tags"] and not isinstance(normalized["tags"], list):
@@ -251,6 +640,10 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
raise serializers.ValidationError("Client does not belong to this workspace.") raise serializers.ValidationError("Client does not belong to this workspace.")
if project_id and not Project.objects.filter(id=project_id, workspace=workspace).exists(): if project_id and not Project.objects.filter(id=project_id, workspace=workspace).exists():
raise serializers.ValidationError("Project does not belong to this workspace.") raise serializers.ValidationError("Project does not belong to this workspace.")
if project_id and not is_workspace_scope:
project = Project.objects.filter(id=project_id, workspace=workspace).first()
if project and not user_has_project_access(actor, project):
raise serializers.ValidationError("Project does not belong to this workspace.")
if tag_ids: if tag_ids:
existing_tag_ids = set(Tag.objects.filter(id__in=tag_ids, workspace=workspace).values_list("id", flat=True)) existing_tag_ids = set(Tag.objects.filter(id__in=tag_ids, workspace=workspace).values_list("id", flat=True))
if len(existing_tag_ids) != len(tag_ids): if len(existing_tag_ids) != len(tag_ids):
@@ -272,8 +665,15 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
def _base_queryset(filters: ReportFilters) -> QuerySet[TimeEntry]: def _base_queryset(filters: ReportFilters) -> QuerySet[TimeEntry]:
start_dt = timezone.make_aware(datetime.combine(filters.from_date, time.min), timezone.get_current_timezone()) current_timezone = timezone.get_current_timezone()
end_dt = timezone.make_aware(datetime.combine(filters.to_date + timedelta(days=1), time.min), timezone.get_current_timezone()) start_dt = timezone.make_aware(
datetime.combine(filters.from_date, time.min),
current_timezone,
)
end_dt = timezone.make_aware(
datetime.combine(filters.to_date + timedelta(days=1), time.min),
current_timezone,
)
queryset = ( queryset = (
TimeEntry.objects.filter( TimeEntry.objects.filter(
@@ -376,9 +776,6 @@ def _bucket_key(filters: ReportFilters, local_dt: datetime) -> tuple[str, date]:
if filters.period in {PERIOD_THIS_WEEK, PERIOD_THIS_MONTH, PERIOD_CUSTOM}: if filters.period in {PERIOD_THIS_WEEK, PERIOD_THIS_MONTH, PERIOD_CUSTOM}:
bucket_date = local_dt.date() bucket_date = local_dt.date()
return bucket_date.isoformat(), bucket_date return bucket_date.isoformat(), bucket_date
if filters.language == "fa":
persian_date = jdatetime.date.fromgregorian(date=local_dt.date())
return f"{persian_date.year:04d}-{persian_date.month:02d}", local_dt.date()
bucket_date = date(local_dt.year, local_dt.month, 1) bucket_date = date(local_dt.year, local_dt.month, 1)
return bucket_date.strftime("%Y-%m"), bucket_date return bucket_date.strftime("%Y-%m"), bucket_date
@@ -387,31 +784,57 @@ def build_chart_report(actor, raw_filters) -> dict:
filters = load_report_filters(actor, raw_filters) filters = load_report_filters(actor, raw_filters)
entries = list(_base_queryset(filters)) entries = list(_base_queryset(filters))
summary = _summary_from_entries(entries) summary = _summary_from_entries(entries)
buckets: dict[str, dict] = {} grouped_entries: dict[str | None, list[TimeEntry]] = defaultdict(list)
if filters.is_workspace_scope and not filters.user_id:
for entry in entries:
grouped_entries[str(entry.user_id)].append(entry)
else:
grouped_entries[filters.user_id] = entries
for entry in entries: serialized_series = []
local_start = _localize_datetime(entry.start_time) for _, series_entries in sorted(
bucket_id, bucket_date = _bucket_key(filters, local_start) grouped_entries.items(),
bucket = buckets.setdefault( key=lambda item: _user_display(item[1][0].user).lower() if item[1] else "",
bucket_id, ):
if not series_entries:
continue
buckets: dict[str, dict] = {}
for entry in series_entries:
local_start = _localize_datetime(entry.start_time)
bucket_id, bucket_date = _bucket_key(filters, local_start)
bucket = buckets.setdefault(
bucket_id,
{
"bucket_key": bucket_id,
"bucket_label": _bucket_label(filters, bucket_date),
"total_seconds": 0,
"total_duration": "00:00:00",
},
)
bucket["total_seconds"] += get_entry_duration_seconds(entry)
serialized_buckets = []
for bucket in sorted(buckets.values(), key=lambda item: item["bucket_key"]):
bucket["total_duration"] = _format_duration_seconds(bucket["total_seconds"])
serialized_buckets.append(bucket)
user = series_entries[0].user
serialized_series.append(
{ {
"bucket_key": bucket_id, "user": {
"bucket_label": _bucket_label(filters, bucket_date), "id": str(user.id),
"total_seconds": 0, "name": _user_display(user),
"total_duration": "00:00:00", "mobile": user.mobile,
}, },
"buckets": serialized_buckets,
}
) )
bucket["total_seconds"] += get_entry_duration_seconds(entry)
serialized_buckets = []
for bucket in sorted(buckets.values(), key=lambda item: item["bucket_key"]):
bucket["total_duration"] = _format_duration_seconds(bucket["total_seconds"])
serialized_buckets.append(bucket)
return { return {
"scope": _scope_payload(filters), "scope": _scope_payload(filters),
"summary": summary, "summary": summary,
"buckets": serialized_buckets, "series": serialized_series,
} }
@@ -430,7 +853,11 @@ def _scope_payload(filters: ReportFilters) -> dict:
"workspace": { "workspace": {
"id": str(filters.workspace.id), "id": str(filters.workspace.id),
"name": filters.workspace.name, "name": filters.workspace.name,
"thumbnail_path": filters.workspace.thumbnail.path if getattr(filters.workspace, "thumbnail", None) else None, "thumbnail_path": (
filters.workspace.thumbnail.path
if getattr(filters.workspace, "thumbnail", None)
else None
),
}, },
"period": filters.period, "period": filters.period,
"from_date": filters.from_date.isoformat(), "from_date": filters.from_date.isoformat(),
@@ -445,17 +872,43 @@ def _scope_payload(filters: ReportFilters) -> dict:
} }
def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> dict: def _table_report_payload(
filters: ReportFilters,
entries: list[TimeEntry],
*,
user_summary: dict | None = None,
user_summaries: list[dict] | None = None,
) -> dict:
summary = _summary_from_entries(entries) summary = _summary_from_entries(entries)
include_latest_rate = not (filters.is_workspace_scope and not filters.user_id) include_latest_rate = not (filters.is_workspace_scope and not filters.user_id)
return { client_rows = _build_breakdown(entries, "clients", language=filters.language)
project_rows = _build_breakdown(entries, "projects", language=filters.language)
tag_rows = _build_breakdown(entries, "tags", language=filters.language)
payload = {
"scope": _scope_payload(filters), "scope": _scope_payload(filters),
"summary": summary, "summary": summary,
"days": _group_daily(entries, include_latest_rate=include_latest_rate), "days": _group_daily(entries, include_latest_rate=include_latest_rate),
"clients": _build_breakdown(entries, "clients"), "clients": client_rows,
"projects": _build_breakdown(entries, "projects"), "projects": project_rows,
"tags": _build_breakdown(entries, "tags"), "tags": tag_rows,
} }
if filters.is_workspace_scope and not filters.user_id:
payload.update(
_build_overall_percentage_payload(
entries,
language=filters.language,
rows_by_kind={
"clients": client_rows,
"projects": project_rows,
"tags": tag_rows,
},
)
)
if user_summary is not None:
payload["user_summary"] = user_summary
if user_summaries is not None:
payload["user_summaries"] = user_summaries
return payload
def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list[dict]: def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list[dict]:
@@ -518,64 +971,31 @@ def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list
return rows return rows
def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]: def _build_breakdown(entries: list[TimeEntry], kind: str, *, language: str) -> list[dict]:
data: dict[str, dict] = {} data: dict[str, dict] = {}
for entry in entries: for entry in entries:
if kind == "clients":
if not entry.project or not entry.project.client:
continue
item_id = str(entry.project.client_id)
item_name = entry.project.client.name
elif kind == "projects":
if not entry.project:
continue
item_id = str(entry.project_id)
item_name = entry.project.name
else:
if not entry.tags.exists():
continue
for tag in entry.tags.all():
bucket = data.setdefault(
str(tag.id),
{
"id": str(tag.id),
"name": tag.name,
"billable_seconds": 0,
"non_billable_seconds": 0,
"total_seconds": 0,
"income": _money_map(),
},
)
duration_seconds = get_entry_duration_seconds(entry)
bucket["total_seconds"] += duration_seconds
if entry.is_billable:
bucket["billable_seconds"] += duration_seconds
else:
bucket["non_billable_seconds"] += duration_seconds
_add_income(bucket["income"], entry)
continue
bucket = data.setdefault(
item_id,
{
"id": item_id,
"name": item_name,
"billable_seconds": 0,
"non_billable_seconds": 0,
"total_seconds": 0,
"income": _money_map(),
},
)
duration_seconds = get_entry_duration_seconds(entry) duration_seconds = get_entry_duration_seconds(entry)
bucket["total_seconds"] += duration_seconds for item_id, item_name in _breakdown_targets(entry, kind, language):
if entry.is_billable: bucket = data.setdefault(
bucket["billable_seconds"] += duration_seconds item_id,
else: {
bucket["non_billable_seconds"] += duration_seconds "id": item_id,
_add_income(bucket["income"], entry) "name": item_name,
"billable_seconds": 0,
"non_billable_seconds": 0,
"total_seconds": 0,
"income": _money_map(),
},
)
bucket["total_seconds"] += duration_seconds
if entry.is_billable:
bucket["billable_seconds"] += duration_seconds
else:
bucket["non_billable_seconds"] += duration_seconds
_add_income(bucket["income"], entry)
rows = [] rows = []
for item_id, bucket in sorted(data.items(), key=lambda item: item[1]["name"].lower()): for _item_id, bucket in sorted(data.items(), key=lambda item: item[1]["name"].lower()):
rows.append( rows.append(
{ {
"id": bucket["id"], "id": bucket["id"],
@@ -595,7 +1015,47 @@ def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]:
def build_table_report(actor, raw_filters) -> dict: def build_table_report(actor, raw_filters) -> dict:
filters = load_report_filters(actor, raw_filters) filters = load_report_filters(actor, raw_filters)
entries = list(_base_queryset(filters)) entries = list(_base_queryset(filters))
return _table_report_payload(filters, entries) if filters.is_workspace_scope and not filters.user_id:
payload = _table_report_payload(
filters,
entries,
user_summaries=_build_user_summaries(entries, filters=filters),
)
return payload
user_summary = (
_build_user_summary(
entries[0].user,
entries,
workspace=filters.workspace,
from_date=filters.from_date,
to_date=filters.to_date,
language=filters.language,
)
if entries and filters.user_id
else None
)
return _table_report_payload(filters, entries, user_summary=user_summary)
def build_user_summary_report(actor, raw_filters) -> dict:
filters = load_report_filters(actor, raw_filters)
if not filters.user_id:
raise serializers.ValidationError("A user is required.")
entries = list(_base_queryset(filters))
user_summary = (
_build_user_summary(
entries[0].user,
entries,
workspace=filters.workspace,
from_date=filters.from_date,
to_date=filters.to_date,
language=filters.language,
)
if entries
else None
)
return _table_report_payload(filters, entries, user_summary=user_summary)
def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]: def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
@@ -616,7 +1076,20 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
reports: list[dict] = [] reports: list[dict] = []
for user_id, user_entries in sorted_groups: for user_id, user_entries in sorted_groups:
user_filters = replace(filters, user_id=user_id) user_filters = replace(filters, user_id=user_id)
reports.append(_table_report_payload(user_filters, user_entries)) reports.append(
_table_report_payload(
user_filters,
user_entries,
user_summary=_build_user_summary(
user_entries[0].user,
user_entries,
workspace=filters.workspace,
from_date=filters.from_date,
to_date=filters.to_date,
language=filters.language,
),
)
)
return reports return reports

View File

@@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from pathlib import Path from pathlib import Path
from typing import Iterable
import jdatetime import jdatetime
from arabic_reshaper import reshape from arabic_reshaper import reshape
@@ -24,6 +24,7 @@ TRANSLATIONS = {
"en": { "en": {
"report_title": "Workspace Report", "report_title": "Workspace Report",
"overall_sheet": "Overall Report", "overall_sheet": "Overall Report",
"users_summary_sheet": "Users Summary",
"workspace": "Workspace", "workspace": "Workspace",
"period": "Period", "period": "Period",
"from_date": "From date", "from_date": "From date",
@@ -38,6 +39,24 @@ TRANSLATIONS = {
"non_billable_hours": "Non-billable hours", "non_billable_hours": "Non-billable hours",
"hourly_rate": "Hourly rate", "hourly_rate": "Hourly rate",
"income": "Income", "income": "Income",
"working_hours": "Working hours",
"non_working_hours": "Non-working hours",
"hourly_rates": "Hourly rates",
"project_percentages": "Project percentages",
"client_percentages": "Client percentages",
"tag_percentages": "Tag percentages",
"summary_by_user": "Summary by user",
"rate_history": "Hourly rate history",
"from": "From",
"to": "To",
"now": "Now",
"project": "Project",
"percentage": "Percentage",
"hour_percentage": "Hour %",
"income_percentage": "Income %",
"multiple_rates": "Multiple rates - see details",
"variable_rate": "Variable rate",
"none": "None",
"daily_summary": "Daily Summary", "daily_summary": "Daily Summary",
"clients": "Clients", "clients": "Clients",
"projects": "Projects", "projects": "Projects",
@@ -46,10 +65,14 @@ TRANSLATIONS = {
"name": "Name", "name": "Name",
"total": "Total", "total": "Total",
"no_data": "No data", "no_data": "No data",
"uncategorized_client": "No client",
"uncategorized_project": "No project",
"uncategorized_tag": "No tag",
}, },
"fa": { "fa": {
"report_title": "گزارش فضای کاری", "report_title": "گزارش فضای کاری",
"overall_sheet": "گزارش کلی", "overall_sheet": "گزارش کلی",
"users_summary_sheet": "خلاصه کاربران",
"workspace": "فضای کاری", "workspace": "فضای کاری",
"period": "بازه", "period": "بازه",
"from_date": "از تاریخ", "from_date": "از تاریخ",
@@ -63,7 +86,25 @@ TRANSLATIONS = {
"billable_hours": "ساعات کاری", "billable_hours": "ساعات کاری",
"non_billable_hours": "ساعات غیر کاری", "non_billable_hours": "ساعات غیر کاری",
"hourly_rate": "نرخ ساعتی", "hourly_rate": "نرخ ساعتی",
"income": "درآمد", "income": "کارکرد",
"working_hours": "ساعات کاری",
"non_working_hours": "ساعات غیرکاری",
"hourly_rates": "نرخ‌های ساعتی",
"project_percentages": "درصد پروژه‌ها",
"client_percentages": "درصد مشتری‌ها",
"tag_percentages": "درصد تگ‌ها",
"summary_by_user": "خلاصه کاربران",
"rate_history": "تاریخچه نرخ ساعتی",
"from": "از",
"to": "تا",
"now": "حال",
"project": "پروژه",
"percentage": "درصد",
"hour_percentage": "درصد ساعت",
"income_percentage": "درصد کارکرد",
"multiple_rates": "چند نرخ - جزئیات در گزارش کاربر",
"variable_rate": "نرخ متغیر",
"none": "بدون مورد",
"daily_summary": "خلاصه روزانه", "daily_summary": "خلاصه روزانه",
"clients": "مشتریان", "clients": "مشتریان",
"projects": "پروژه‌ها", "projects": "پروژه‌ها",
@@ -72,6 +113,9 @@ TRANSLATIONS = {
"name": "نام", "name": "نام",
"total": "جمع", "total": "جمع",
"no_data": "بدون داده", "no_data": "بدون داده",
"uncategorized_client": "بدون مشتری",
"uncategorized_project": "بدون پروژه",
"uncategorized_tag": "بدون تگ",
}, },
} }
@@ -104,6 +148,8 @@ CURRENCY_LABELS = {
"TRY": {"en": "TRY", "fa": "لیر"}, "TRY": {"en": "TRY", "fa": "لیر"},
} }
DECIMAL_TRIM_CURRENCIES = {"IRR", "IRT"}
@dataclass(frozen=True) @dataclass(frozen=True)
class ExportLocale: class ExportLocale:
@@ -138,6 +184,15 @@ class ExportLocale:
return self.format_number(value, ascii_digits=ascii_digits) return self.format_number(value, ascii_digits=ascii_digits)
def format_amount(self, value: object, *, ascii_digits: bool = False) -> str: def format_amount(self, value: object, *, ascii_digits: bool = False) -> str:
return self.format_amount_for_currency(value, None, ascii_digits=ascii_digits)
def format_amount_for_currency(
self,
value: object,
currency: str | None,
*,
ascii_digits: bool = False,
) -> str:
raw = str(value).strip() raw = str(value).strip()
if not raw: if not raw:
return raw return raw
@@ -153,18 +208,24 @@ class ExportLocale:
grouped_integer = f"{int(integer_part):,}" grouped_integer = f"{int(integer_part):,}"
formatted = f"{sign}{grouped_integer}" formatted = f"{sign}{grouped_integer}"
if fractional_part: if fractional_part:
trimmed_fraction = fractional_part.rstrip("0") trimmed_fraction = (
""
if str(currency or "").upper() in DECIMAL_TRIM_CURRENCIES
else fractional_part.rstrip("0")
)
if trimmed_fraction: if trimmed_fraction:
formatted = f"{formatted}.{trimmed_fraction}" formatted = f"{formatted}.{trimmed_fraction}"
return self.format_number(formatted, ascii_digits=ascii_digits) return self.format_number(formatted, ascii_digits=ascii_digits)
def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str: def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str:
if not income_totals: if not income_totals:
return "-" return self.format_number("0", ascii_digits=ascii_digits)
parts = [] parts = []
for item in income_totals: for item in income_totals:
currency = self.currency_label(item["currency"]) currency = self.currency_label(item["currency"])
parts.append(f"{self.format_amount(item['amount'], ascii_digits=ascii_digits)} {currency}") parts.append(
f"{self.format_amount_for_currency(item['amount'], item['currency'], ascii_digits=ascii_digits)} {currency}"
)
return " | ".join(parts) return " | ".join(parts)
def currency_label(self, code: str | None) -> str: def currency_label(self, code: str | None) -> str:
@@ -199,7 +260,7 @@ def user_label(user_payload: dict | None, locale: ExportLocale, *, ascii_digits:
def safe_sheet_title(title: str, used: Iterable[str]) -> str: def safe_sheet_title(title: str, used: Iterable[str]) -> str:
invalid = set('[]:*?/\\') invalid = set("[]:*?/\\")
sanitized = "".join("-" if char in invalid else char for char in title).strip() or "Sheet" sanitized = "".join("-" if char in invalid else char for char in title).strip() or "Sheet"
base = sanitized[:31] base = sanitized[:31]
used_set = set(used) used_set = set(used)

File diff suppressed because it is too large Load Diff

View File

@@ -30,13 +30,13 @@ def generate_report_export_task(job_id: str):
try: try:
locale = build_export_locale(job.filters.get("language")) locale = build_export_locale(job.filters.get("language"))
report_data = build_table_report(job.requesting_user, job.filters) report_data = build_table_report(job.requesting_user, job.filters)
per_user_reports = build_user_scoped_table_reports(job.requesting_user, job.filters)
if job.export_type == ReportExportJob.ExportType.EXCEL: if job.export_type == ReportExportJob.ExportType.EXCEL:
per_user_reports = build_user_scoped_table_reports(job.requesting_user, job.filters)
content = build_excel_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports) content = build_excel_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
suffix = "xlsx" suffix = "xlsx"
mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else: else:
content = build_pdf_report(report_data=report_data, locale=locale) content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
suffix = "pdf" suffix = "pdf"
mime_type = "application/pdf" mime_type = "application/pdf"

View File

@@ -0,0 +1,105 @@
from datetime import date
from types import SimpleNamespace
from unittest.mock import patch
from django.core.files.base import ContentFile
from django.test import TestCase
from rest_framework.test import APIClient
from apps.reports.models import ReportExportJob
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
class ReportExportApiTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(
mobile="09126660001",
password="secret123",
first_name="Owner",
)
cls.admin = User.objects.create_user(
mobile="09126660002",
password="secret123",
first_name="Admin",
)
cls.workspace = Workspace.objects.create(name="Exports", owner=cls.owner)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.admin,
role=WorkspaceMembership.Role.ADMIN,
is_active=True,
)
def setUp(self):
self.client = APIClient()
def test_create_export_job_enqueues_background_task(self):
self.client.force_authenticate(user=self.owner)
filters = SimpleNamespace(
workspace=self.workspace,
period="this_month",
from_date=date(2026, 4, 1),
to_date=date(2026, 4, 30),
user_id=None,
client_id=None,
project_id=None,
tag_ids=[],
)
with patch("apps.reports.api.views.load_report_filters", return_value=filters):
with patch("apps.reports.api.views.generate_report_export_task.delay") as delay:
response = self.client.post(
"/api/reports/exports/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"export_type": "excel",
"language": "en",
},
format="json",
)
self.assertEqual(response.status_code, 202)
self.assertEqual(ReportExportJob.objects.count(), 1)
delay.assert_called_once()
def test_list_only_returns_requesting_users_jobs(self):
own_job = ReportExportJob.objects.create(
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={"workspace": str(self.workspace.id)},
)
ReportExportJob.objects.create(
requesting_user=self.admin,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.PDF,
filters={"workspace": str(self.workspace.id)},
)
self.client.force_authenticate(user=self.owner)
response = self.client.get("/api/reports/exports/")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["id"], str(own_job.id))
def test_download_returns_completed_file(self):
job = ReportExportJob.objects.create(
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.EXCEL,
status=ReportExportJob.Status.COMPLETED,
filters={"workspace": str(self.workspace.id)},
file_name="report.xlsx",
)
job.file.save("reports/exports/report.xlsx", ContentFile(b"content"), save=False)
job.save(update_fields=["file", "updated_at"])
self.client.force_authenticate(user=self.owner)
response = self.client.get(f"/api/reports/exports/{job.id}/download/")
self.assertEqual(response.status_code, 200)
self.assertIn("attachment; filename=", response["Content-Disposition"])

View File

@@ -0,0 +1,296 @@
from io import BytesIO
from django.test import TestCase
from openpyxl import load_workbook
from apps.reports.services.export_i18n import build_export_locale
from apps.reports.services.exporters import (
_pdf_summary_rate_label,
_rate_label,
_sort_breakdown_rows,
build_excel_report,
build_pdf_report,
)
def make_report_data(*, user_name="Owner User", mobile="09129990001", hourly_rate=None):
return {
"scope": {
"workspace": {"name": "Exports", "thumbnail_path": None},
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": {"name": user_name, "mobile": mobile} if user_name else None,
},
"summary": {
"total_duration": "02:00:00",
"billable_duration": "02:00:00",
"non_billable_duration": "00:00:00",
"income_totals": [{"amount": "30.00", "currency": "USD"}],
},
"days": [
{
"date": "2026-04-12",
"billable_duration": "02:00:00",
"non_billable_duration": "00:00:00",
"total_duration": "02:00:00",
"latest_hourly_rate": hourly_rate,
"income_totals": [{"amount": "30.00", "currency": "USD"}],
}
],
"clients": [
{
"name": "Acme",
"billable_duration": "02:00:00",
"non_billable_duration": "00:00:00",
"total_duration": "02:00:00",
"income_totals": [{"amount": "30.00", "currency": "USD"}],
}
],
"projects": [],
"tags": [],
}
def make_user_summary(*, name: str, mobile: str):
return {
"user": {"id": mobile, "name": name, "mobile": mobile},
"hourly_rates": [{"amount": "15.00", "currency": "USD"}],
"rate_periods": [
{
"amount": "15.00",
"currency": "USD",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
}
],
"total_seconds": 7200,
"total_duration": "02:00:00",
"billable_seconds": 7200,
"billable_duration": "02:00:00",
"non_billable_seconds": 0,
"non_billable_duration": "00:00:00",
"income_totals": [{"amount": "30.00", "currency": "USD"}],
"project_percentages": [{"id": "1", "name": "Website", "percentage": "100"}],
"client_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}],
"tag_percentages": [{"id": "1", "name": "Design", "percentage": "100"}],
"project_income_percentages": [{"id": "1", "name": "Website", "percentage": "100"}],
"client_income_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}],
"tag_income_percentages": [{"id": "1", "name": "Design", "percentage": "100"}],
}
def make_variable_user_summary(*, name: str, mobile: str):
summary = make_user_summary(name=name, mobile=mobile)
summary["hourly_rates"] = [
{"amount": "15.00", "currency": "USD"},
{"amount": "18.00", "currency": "USD"},
]
summary["rate_periods"] = [
{
"amount": "15.00",
"currency": "USD",
"from_date": "2026-04-01",
"to_date": "2026-04-14",
},
{
"amount": "18.00",
"currency": "USD",
"from_date": "2026-04-15",
"to_date": "2026-04-30",
},
]
return summary
class ReportExporterTests(TestCase):
def test_export_rate_labels_trim_rial_and_toman_decimals(self):
locale = build_export_locale("en")
self.assertEqual(
_rate_label(locale, {"amount": "1250.75", "currency": "USD"}),
"1,250.75 USD",
)
self.assertEqual(
_rate_label(locale, {"amount": "1250.75", "currency": "IRR"}),
"1,250 IRR",
)
self.assertEqual(
_rate_label(locale, {"amount": "9800.50", "currency": "IRT"}),
"9,800 IRT",
)
def test_pdf_summary_uses_multiple_rates_label(self):
locale = build_export_locale("en")
self.assertEqual(
_pdf_summary_rate_label(
locale,
[
{"amount": "15.00", "currency": "USD"},
{"amount": "18.00", "currency": "USD"},
],
),
"Variable rate",
)
def test_breakdown_rows_are_sorted_by_hour_percentage(self):
rows = [
{"id": "low", "name": "Low", "billable_seconds": 7200},
{"id": "high", "name": "High", "billable_seconds": 3600},
{"id": "tie", "name": "Tie", "billable_seconds": 10800},
]
percentages = [
{"id": "low", "name": "Low", "percentage": "20"},
{"id": "high", "name": "High", "percentage": "70"},
{"id": "tie", "name": "Tie", "percentage": "20"},
]
self.assertEqual(
[row["name"] for row in _sort_breakdown_rows(rows, percentages)],
["High", "Tie", "Low"],
)
def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self):
locale = build_export_locale("en")
report_data = make_report_data(
hourly_rate={"amount": "15.00", "currency": "USD"},
)
report_data["user_summaries"] = [
make_variable_user_summary(name="Owner User", mobile="09129990001"),
make_user_summary(name="Team Mate", mobile="09129990002"),
]
per_user_reports = [
{
**make_report_data(
user_name="Owner User",
mobile="09129990001",
hourly_rate={"amount": "15.00", "currency": "USD"},
),
"user_summary": make_variable_user_summary(name="Owner User", mobile="09129990001"),
},
{
**make_report_data(
user_name="Team Mate",
mobile="09129990002",
hourly_rate={"amount": "15.00", "currency": "USD"},
),
"user_summary": make_user_summary(name="Team Mate", mobile="09129990002"),
},
]
workbook = load_workbook(
BytesIO(
build_excel_report(
report_data=report_data,
locale=locale,
per_user_reports=per_user_reports,
)
)
)
self.assertEqual(workbook.sheetnames[0], "Overall Report")
self.assertIn("Owner User", workbook.sheetnames[1])
self.assertIn("Team Mate", workbook.sheetnames[2])
summary_sheet = workbook[workbook.sheetnames[0]]
summary_values = list(summary_sheet.iter_rows(values_only=True))
self.assertEqual(summary_sheet.freeze_panes, "B1")
self.assertEqual(summary_sheet["A1"].value, "Workspace Report")
self.assertEqual(summary_sheet["B1"].value, "Exports")
self.assertEqual(summary_sheet["A15"].value, "Users Summary")
merged_ranges = {str(item) for item in summary_sheet.merged_cells.ranges}
self.assertIn("A15:F15", merged_ranges)
self.assertIn("H15:J15", merged_ranges)
self.assertIn("L15:N15", merged_ranges)
self.assertIn("P15:R15", merged_ranges)
self.assertNotIn("A15:R15", merged_ranges)
self.assertIsNone(summary_sheet["G15"].fill.fill_type)
self.assertIsNone(summary_sheet["G16"].fill.fill_type)
self.assertIsNone(summary_sheet["K15"].fill.fill_type)
self.assertIsNone(summary_sheet["O15"].fill.fill_type)
self.assertEqual(
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:18],
(
"Name",
"Mobile",
"Working hours",
"Hourly rate",
"Period",
"Income",
None,
"Clients",
"Hour %",
"Income %",
None,
"Projects",
"Hour %",
"Income %",
None,
"Tags",
"Hour %",
"Income %",
),
)
self.assertTrue(any(row and "Owner User" in row for row in summary_values))
self.assertTrue(any(row and "09129990001" in row for row in summary_values))
self.assertTrue(any(row and "Variable rate" in row for row in summary_values))
self.assertEqual(summary_sheet["A17"].border.top.style, "medium")
self.assertEqual(summary_sheet["A18"].border.top.style, "medium")
self.assertIsNone(summary_sheet["G17"].border.top)
user_sheet = workbook[workbook.sheetnames[1]]
user_values = list(user_sheet.iter_rows(values_only=True))
self.assertEqual(user_sheet.freeze_panes, "B1")
daily_header = next(row[:6] for row in user_values if row and "Date" in row)
self.assertEqual(
daily_header,
(
"Date",
"Billable hours",
"Non-billable hours",
"Total hours",
"Hourly rate",
"Income",
),
)
daily_row = next(row[:6] for row in user_values if row and "2026/04/12" in row)
self.assertEqual(daily_row[4], "15 USD")
breakdown_header = next(row[:7] for row in user_values if row and row[0] == "Name" and row[2] == "Hour %")
self.assertEqual(
breakdown_header[:5],
(
"Name",
"Billable hours",
"Hour %",
"Income",
"Income %",
),
)
def test_pdf_export_supports_persian_locale(self):
locale = build_export_locale("fa")
report_data = make_report_data(
hourly_rate={"amount": "15.00", "currency": "USD"},
)
report_data["user_summaries"] = [make_user_summary(name="Owner User", mobile="09129990001")]
per_user_reports = [
{
**make_report_data(
user_name="Owner User",
mobile="09129990001",
),
"user_summary": make_user_summary(
name="Owner User",
mobile="09129990001",
),
}
]
content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
self.assertEqual(content[:4], b"%PDF")

View File

@@ -1,264 +1,111 @@
from datetime import timedelta from unittest.mock import patch
from decimal import Decimal
from io import BytesIO
import pytest
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from openpyxl import load_workbook
from apps.notifications.services import store as notification_store
from apps.reports.models import ReportExportJob from apps.reports.models import ReportExportJob
from apps.reports.tasks import cleanup_expired_report_exports_task, generate_report_export_task from apps.reports.tasks import (
from apps.time_entries.models import TimeEntry cleanup_expired_report_exports_task,
generate_report_export_task,
)
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace from apps.workspaces.models import Workspace
class FakeRedis: class ReportTaskTests(TestCase):
def pipeline(self): @classmethod
return self def setUpTestData(cls):
cls.owner = User.objects.create_user(
mobile="09129990001",
password="secret123",
first_name="Owner",
last_name="User",
)
cls.workspace = Workspace.objects.create(name="Exports", owner=cls.owner)
def zadd(self, *args, **kwargs): def test_generate_excel_export_marks_job_complete_and_sends_notification(self):
return self job = ReportExportJob.objects.create(
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={
"workspace": str(self.workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(self.owner.id),
"client": None,
"project": None,
"tags": [],
"language": "en",
},
)
def hset(self, *args, **kwargs): with patch("apps.reports.tasks.build_table_report", return_value={"scope": {}, "summary": {}, "days": [], "clients": [], "projects": [], "tags": []}) as build_table_report:
return self with patch("apps.reports.tasks.build_user_scoped_table_reports", return_value=[]) as build_user_reports:
with patch("apps.reports.tasks.build_excel_report", return_value=b"excel-content") as build_excel_report:
with patch("apps.reports.tasks.RedisNotificationStore.add") as notify:
generate_report_export_task(str(job.id))
def sadd(self, *args, **kwargs): job.refresh_from_db()
return self self.assertEqual(job.status, ReportExportJob.Status.COMPLETED)
self.assertTrue(bool(job.file))
self.assertTrue(default_storage.exists(job.file.name))
build_table_report.assert_called_once()
build_user_reports.assert_called_once()
build_excel_report.assert_called_once()
notify.assert_called_once()
self.assertEqual(notify.call_args.args[0], str(self.owner.id))
self.assertEqual(notify.call_args.args[1]["type"], "report_export_ready")
def execute(self): def test_generate_pdf_export_failure_marks_job_failed_and_notifies(self):
return [] job = ReportExportJob.objects.create(
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.PDF,
filters={
"workspace": str(self.workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(self.owner.id),
"client": None,
"project": None,
"tags": [],
"language": "fa",
},
)
def publish(self, *args, **kwargs): with patch("apps.reports.tasks.build_table_report", return_value={"scope": {}, "summary": {}, "days": [], "clients": [], "projects": [], "tags": []}):
return None with patch("apps.reports.tasks.build_pdf_report", side_effect=RuntimeError("boom")):
with patch("apps.reports.tasks.RedisNotificationStore.add") as notify:
with self.assertRaises(RuntimeError):
generate_report_export_task(str(job.id))
def zrevrange(self, *args, **kwargs): job.refresh_from_db()
return [] self.assertEqual(job.status, ReportExportJob.Status.FAILED)
self.assertEqual(job.error_message, "boom")
notify.assert_called_once()
self.assertEqual(notify.call_args.args[1]["type"], "report_export_failed")
def hget(self, *args, **kwargs): def test_cleanup_expires_and_removes_files(self):
return None job = ReportExportJob.objects.create(
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.EXCEL,
status=ReportExportJob.Status.COMPLETED,
filters={},
expires_at=timezone.now() - timezone.timedelta(days=1),
)
file_name = f"reports/exports/{job.id}-old.xlsx"
job.file.save(file_name, ContentFile(b"old-data"), save=False)
job.save(update_fields=["file", "updated_at"])
def zrem(self, *args, **kwargs): removed = cleanup_expired_report_exports_task()
return 1 job.refresh_from_db()
def hdel(self, *args, **kwargs): self.assertEqual(removed, 1)
return 1 self.assertEqual(job.status, ReportExportJob.Status.EXPIRED)
self.assertFalse(default_storage.exists(file_name))
def zcard(self, *args, **kwargs):
return 0
def smembers(self, *args, **kwargs):
return set()
def srem(self, *args, **kwargs):
return 1
@pytest.fixture()
def fake_redis(monkeypatch):
redis = FakeRedis()
monkeypatch.setattr(notification_store, "redis_client", redis)
return redis
@pytest.fixture()
def owner(db):
return User.objects.create_user(mobile="09129990001", password="secret123", first_name="Owner", last_name="User")
@pytest.fixture()
def teammate(db):
return User.objects.create_user(mobile="09129990002", password="secret123", first_name="Team", last_name="Mate")
@pytest.fixture()
def workspace(owner, teammate):
workspace = Workspace.objects.create(name="Exports", owner=owner)
workspace.memberships.create(user=teammate, role="member", is_active=True)
return workspace
@pytest.fixture()
def time_entry(workspace, owner):
return TimeEntry.objects.create(
workspace=workspace,
user=owner,
description="Export row",
start_time="2026-04-12T08:00:00+03:30",
end_time="2026-04-12T10:00:00+03:30",
duration=timedelta(hours=2),
is_billable=True,
hourly_rate=Decimal("15.00"),
currency="USD",
)
@pytest.fixture()
def teammate_entry(workspace, teammate):
return TimeEntry.objects.create(
workspace=workspace,
user=teammate,
description="Team row",
start_time="2026-04-13T08:00:00+03:30",
end_time="2026-04-13T09:00:00+03:30",
duration=timedelta(hours=1),
is_billable=False,
currency="USD",
)
def test_generate_export_creates_file_and_marks_job_complete(fake_redis, workspace, owner, time_entry):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={
"workspace": str(workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(owner.id),
"client": None,
"project": None,
"tags": [],
"language": "en",
},
)
generate_report_export_task(str(job.id))
job.refresh_from_db()
assert job.status == ReportExportJob.Status.COMPLETED
assert bool(job.file)
assert default_storage.exists(job.file.name)
def test_generate_excel_export_adds_per_user_sheets_for_all_users_scope(
fake_redis,
workspace,
owner,
teammate,
time_entry,
teammate_entry,
):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={
"workspace": str(workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": None,
"client": None,
"project": None,
"tags": [],
"language": "en",
},
)
generate_report_export_task(str(job.id))
job.refresh_from_db()
workbook = load_workbook(BytesIO(job.file.read()))
assert workbook.sheetnames[0] == "Overall Report"
assert any("Owner User" in sheet for sheet in workbook.sheetnames[1:])
assert any("Team Mate" in sheet for sheet in workbook.sheetnames[1:])
assert len(workbook.sheetnames) == 3
def test_generate_excel_export_includes_daily_rate_column_and_split_user_meta(
fake_redis,
workspace,
owner,
time_entry,
):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={
"workspace": str(workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(owner.id),
"client": None,
"project": None,
"tags": [],
"language": "en",
},
)
generate_report_export_task(str(job.id))
job.refresh_from_db()
workbook = load_workbook(BytesIO(job.file.read()))
worksheet = workbook.active
values = list(worksheet.iter_rows(values_only=True))
assert any(row[:2] == ("User", "Owner User") for row in values if row)
assert any(row[:2] == ("Mobile", "09129990001") for row in values if row)
daily_header = next(row[:6] for row in values if row and row[0] == "Date")
assert daily_header == (
"Date",
"Billable hours",
"Non-billable hours",
"Total hours",
"Hourly rate",
"Income",
)
daily_row = next(row[:6] for row in values if row and row[0] == "2026/04/12")
assert daily_row[4] == "15 USD"
def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owner, time_entry):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.PDF,
filters={
"workspace": str(workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(owner.id),
"client": None,
"project": None,
"tags": [],
"language": "fa",
},
)
generate_report_export_task(str(job.id))
job.refresh_from_db()
assert job.status == ReportExportJob.Status.COMPLETED
assert job.file.read(4) == b"%PDF"
def test_cleanup_expires_and_removes_files(fake_redis, workspace, owner):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.EXCEL,
status=ReportExportJob.Status.COMPLETED,
filters={},
expires_at=timezone.now() - timezone.timedelta(days=1),
)
file_name = f"reports/exports/{job.id}-old.xlsx"
job.file.save(file_name, ContentFile(b"old-data"), save=False)
job.save(update_fields=["file", "updated_at"])
removed = cleanup_expired_report_exports_task()
job.refresh_from_db()
assert removed == 1
assert job.status == ReportExportJob.Status.EXPIRED
assert not default_storage.exists(file_name)

View File

@@ -1,205 +1,484 @@
from datetime import date, timedelta from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from unittest.mock import patch
import pytest from django.core.cache import cache
from rest_framework.test import APIClient from rest_framework.test import APITestCase
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import Project from apps.projects.models import Project
from apps.tags.models import Tag from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry from apps.time_entries.models import TimeEntry
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
@pytest.fixture() class ReportViewTests(APITestCase):
def api_client(): @classmethod
return APIClient() def setUpTestData(cls):
cls.owner = User.objects.create_user(
mobile="09128880001",
password="secret123",
first_name="Owner",
)
cls.admin = User.objects.create_user(
mobile="09128880002",
password="secret123",
first_name="Admin",
)
cls.member = User.objects.create_user(
mobile="09128880003",
password="secret123",
first_name="Member",
)
cls.workspace = Workspace.objects.create(name="Reports", owner=cls.owner)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.admin,
role=WorkspaceMembership.Role.ADMIN,
is_active=True,
)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
cls.client_obj = Client.objects.create(workspace=cls.workspace, name="Acme")
cls.project = Project.objects.create(
workspace=cls.workspace,
name="Website",
client=cls.client_obj,
)
cls.tag = Tag.objects.create(
workspace=cls.workspace,
name="Design",
color="#ffffff",
)
entry_owner = TimeEntry.objects.create(
workspace=cls.workspace,
user=cls.owner,
project=cls.project,
description="Owner work",
start_time="2026-04-10T08:00:00+03:30",
end_time="2026-04-10T10:00:00+03:30",
duration=timedelta(hours=2),
is_billable=True,
hourly_rate=Decimal("25.00"),
currency="USD",
)
entry_owner.tags.add(cls.tag)
@pytest.fixture() entry_member = TimeEntry.objects.create(
def owner(db): workspace=cls.workspace,
return User.objects.create_user(mobile="09128880001", password="secret123", first_name="Owner") user=cls.member,
project=cls.project,
description="Member work",
start_time="2026-04-11T09:00:00+03:30",
end_time="2026-04-11T10:00:00+03:30",
duration=timedelta(hours=1),
is_billable=False,
currency="USD",
)
entry_member.tags.add(cls.tag)
def setUp(self):
cache.clear()
@pytest.fixture() def test_member_only_sees_own_chart_report(self):
def admin(db): self.client.force_authenticate(user=self.member)
return User.objects.create_user(mobile="09128880002", password="secret123", first_name="Admin")
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/chart/",
{"workspace": str(self.workspace.id), "period": "this_month"},
)
@pytest.fixture() self.assertEqual(response.status_code, 200)
def member(db): self.assertEqual(response.data["summary"]["total_duration"], "01:00:00")
return User.objects.create_user(mobile="09128880003", password="secret123", first_name="Member") self.assertEqual(len(response.data["series"]), 1)
self.assertEqual(response.data["series"][0]["user"]["id"], str(self.member.id))
def test_admin_chart_without_user_filter_returns_series_for_all_users(self):
self.client.force_authenticate(user=self.admin)
@pytest.fixture() with patch(
def workspace(owner, admin, member): "apps.reports.services.aggregation.timezone.localdate",
workspace = Workspace.objects.create(name="Reports", owner=owner) return_value=date(2026, 4, 20),
WorkspaceMembership.objects.create(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True) ):
WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True) response = self.client.get(
return workspace "/api/reports/chart/",
{"workspace": str(self.workspace.id), "period": "this_month"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
self.assertEqual(len(response.data["series"]), 2)
self.assertEqual(
{series["user"]["id"] for series in response.data["series"]},
{str(self.owner.id), str(self.member.id)},
)
@pytest.fixture() def test_admin_can_request_combined_table_report(self):
def client(workspace): self.client.force_authenticate(user=self.admin)
return Client.objects.create(workspace=workspace, name="Acme")
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/table/",
{"workspace": str(self.workspace.id), "period": "this_month"},
)
@pytest.fixture() self.assertEqual(response.status_code, 200)
def project(workspace, client): self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
return Project.objects.create(workspace=workspace, name="Website", client=client) self.assertEqual(len(response.data["days"]), 2)
self.assertEqual(len(response.data["user_summaries"]), 2)
self.assertIsNone(response.data["days"][0]["latest_hourly_rate"])
self.assertIsNone(response.data["days"][1]["latest_hourly_rate"])
summaries = {item["user"]["id"]: item for item in response.data["user_summaries"]}
owner_summary = summaries[str(self.owner.id)]
member_summary = summaries[str(self.member.id)]
self.assertEqual(owner_summary["project_percentages"][0]["percentage"], "100")
self.assertEqual(owner_summary["client_percentages"][0]["percentage"], "100")
self.assertEqual(owner_summary["tag_percentages"][0]["percentage"], "100")
self.assertEqual(member_summary["project_percentages"][0]["percentage"], "0")
self.assertEqual(member_summary["client_percentages"][0]["percentage"], "0")
self.assertEqual(member_summary["tag_percentages"][0]["percentage"], "0")
def test_specific_user_report_includes_uncategorized_rows_and_balanced_percentages(self):
self.client.force_authenticate(user=self.owner)
@pytest.fixture() TimeEntry.objects.create(
def tag(workspace): workspace=self.workspace,
return Tag.objects.create(workspace=workspace, name="Design", color="#ffffff") user=self.owner,
project=None,
description="Uncategorized billable",
start_time="2026-04-12T10:00:00+03:30",
end_time="2026-04-12T11:00:00+03:30",
duration=timedelta(hours=1),
is_billable=True,
hourly_rate=Decimal("10.00"),
currency="USD",
)
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/table/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"user": str(self.owner.id),
"language": "en",
},
)
@pytest.fixture() self.assertEqual(response.status_code, 200)
def time_entries(workspace, owner, member, project, tag): summary = response.data["user_summary"]
entry_owner = TimeEntry.objects.create( self.assertEqual(
workspace=workspace, sum(int(row["percentage"]) for row in summary["project_percentages"]),
user=owner, 100,
project=project, )
description="Owner work", self.assertEqual(
start_time="2026-04-10T08:00:00+03:30", sum(int(row["percentage"]) for row in summary["client_percentages"]),
end_time="2026-04-10T10:00:00+03:30", 100,
duration=timedelta(hours=2), )
is_billable=True, self.assertEqual(
hourly_rate=Decimal("25.00"), sum(int(row["percentage"]) for row in summary["tag_percentages"]),
currency="USD", 100,
) )
entry_owner.tags.add(tag) self.assertEqual(
entry_member = TimeEntry.objects.create( {row["name"] for row in summary["project_percentages"]},
workspace=workspace, {"Website", "No project"},
user=member, )
project=project, self.assertEqual(
description="Member work", {row["name"] for row in summary["client_percentages"]},
start_time="2026-04-11T09:00:00+03:30", {"Acme", "No client"},
end_time="2026-04-11T10:00:00+03:30", )
duration=timedelta(hours=1), self.assertEqual(
is_billable=False, {row["name"] for row in summary["tag_percentages"]},
currency="USD", {"Design", "No tag"},
) )
entry_member.tags.add(tag) self.assertEqual(
return [entry_owner, entry_member] {row["name"] for row in response.data["projects"]},
{"Website", "No project"},
)
self.assertEqual(
{row["name"] for row in response.data["clients"]},
{"Acme", "No client"},
)
self.assertEqual(
{row["name"] for row in response.data["tags"]},
{"Design", "No tag"},
)
self.assertEqual(
sum(int(row["percentage"]) for row in summary["project_income_percentages"]),
100,
)
self.assertEqual(
sum(int(row["percentage"]) for row in summary["client_income_percentages"]),
100,
)
self.assertEqual(
sum(int(row["percentage"]) for row in summary["tag_income_percentages"]),
100,
)
def test_income_percentages_are_hidden_for_mixed_currency_breakdowns(self):
self.client.force_authenticate(user=self.owner)
second_project = Project.objects.create(
workspace=self.workspace,
name="Mobile App",
client=self.client_obj,
)
def test_member_only_sees_own_chart_report(api_client, member, workspace, time_entries): TimeEntry.objects.create(
api_client.force_authenticate(user=member) workspace=self.workspace,
user=self.owner,
project=second_project,
description="EUR work",
start_time="2026-04-13T10:00:00+03:30",
end_time="2026-04-13T11:00:00+03:30",
duration=timedelta(hours=1),
is_billable=True,
hourly_rate=Decimal("20.00"),
currency="EUR",
)
response = api_client.get( with patch(
"/api/reports/chart/", "apps.reports.services.aggregation.timezone.localdate",
{"workspace": str(workspace.id), "period": "this_month"}, return_value=date(2026, 4, 20),
) ):
response = self.client.get(
"/api/reports/table/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"user": str(self.owner.id),
"language": "en",
},
)
assert response.status_code == 200 self.assertEqual(response.status_code, 200)
assert response.data["summary"]["total_duration"] == "01:00:00" summary = response.data["user_summary"]
self.assertEqual(summary["project_income_percentages"], [])
self.assertEqual(summary["client_income_percentages"], [])
def test_daily_rate_uses_latest_billable_entry_snapshot(self):
self.client.force_authenticate(user=self.owner)
def test_admin_can_request_combined_table_report(api_client, admin, workspace, time_entries): TimeEntry.objects.create(
api_client.force_authenticate(user=admin) workspace=self.workspace,
user=self.owner,
project=self.project,
description="Morning work",
start_time="2026-04-15T08:00:00+03:30",
end_time="2026-04-15T09:00:00+03:30",
duration=timedelta(hours=1),
is_billable=True,
hourly_rate=Decimal("20.00"),
currency="USD",
)
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=self.project,
description="Later work",
start_time="2026-04-15T13:00:00+03:30",
end_time="2026-04-15T15:00:00+03:30",
duration=timedelta(hours=2),
is_billable=True,
hourly_rate=Decimal("35.00"),
currency="USD",
)
response = api_client.get( with patch(
"/api/reports/table/", "apps.reports.services.aggregation.timezone.localdate",
{"workspace": str(workspace.id), "period": "this_month"}, return_value=date(2026, 4, 20),
) ):
response = self.client.get(
"/api/reports/table/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"user": str(self.owner.id),
},
)
assert response.status_code == 200 self.assertEqual(response.status_code, 200)
assert response.data["summary"]["total_duration"] == "03:00:00" target_day = next(day for day in response.data["days"] if day["date"] == "2026-04-15")
assert len(response.data["days"]) == 2 self.assertEqual(
assert response.data["days"][0]["latest_hourly_rate"] is None target_day["latest_hourly_rate"],
assert response.data["days"][1]["latest_hourly_rate"] is None {"amount": "35.00", "currency": "USD"},
)
def test_user_summary_endpoint_keeps_workspace_rate_history_and_marks_current_row_open(self):
self.client.force_authenticate(user=self.owner)
def test_daily_rate_uses_latest_billable_entry_snapshot(api_client, owner, workspace, project): TimeEntry.objects.create(
api_client.force_authenticate(user=owner) workspace=self.workspace,
user=self.owner,
project=None,
description="Legacy workspace rate",
start_time="2026-04-08T08:00:00+03:30",
end_time="2026-04-08T09:00:00+03:30",
duration=timedelta(hours=1),
is_billable=True,
hourly_rate=Decimal("12.00"),
currency="USD",
)
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=self.project,
description="Current project rate",
start_time="2026-04-12T08:00:00+03:30",
end_time="2026-04-12T10:00:00+03:30",
duration=timedelta(hours=2),
is_billable=True,
hourly_rate=Decimal("25.00"),
currency="USD",
)
WorkspaceUserRate.objects.create(
workspace=self.workspace,
user=self.owner,
hourly_rate=Decimal("12.00"),
currency="USD",
effective_from="2026-04-01T00:00:00+03:30",
is_active=True,
)
TimeEntry.objects.create( with patch(
workspace=workspace, "apps.reports.services.aggregation.timezone.localdate",
user=owner, return_value=date(2026, 4, 20),
project=project, ):
description="Morning work", response = self.client.get(
start_time="2026-04-15T08:00:00+03:30", "/api/reports/user-summary/",
end_time="2026-04-15T09:00:00+03:30", {
duration=timedelta(hours=1), "workspace": str(self.workspace.id),
is_billable=True, "period": "this_month",
hourly_rate=Decimal("20.00"), "user": str(self.owner.id),
currency="USD", },
) )
TimeEntry.objects.create(
workspace=workspace,
user=owner,
project=project,
description="Later work",
start_time="2026-04-15T13:00:00+03:30",
end_time="2026-04-15T15:00:00+03:30",
duration=timedelta(hours=2),
is_billable=True,
hourly_rate=Decimal("35.00"),
currency="USD",
)
response = api_client.get( self.assertEqual(response.status_code, 200)
"/api/reports/table/", rate_periods = response.data["user_summary"]["rate_periods"]
{"workspace": str(workspace.id), "period": "this_month", "user": str(owner.id)}, self.assertEqual(
) rate_periods,
[
{
"amount": "12.00",
"currency": "USD",
"from_date": "2026-04-08",
"to_date": None,
},
{
"amount": "25.00",
"currency": "USD",
"from_date": "2026-04-10",
"to_date": "2026-04-12",
},
],
)
assert response.status_code == 200 def test_custom_period_longer_than_31_days_is_rejected(self):
assert response.data["days"][0]["latest_hourly_rate"] == { self.client.force_authenticate(user=self.owner)
"amount": "35.00",
"currency": "USD",
}
response = self.client.get(
"/api/reports/chart/",
{
"workspace": str(self.workspace.id),
"period": "period",
"from_date": "2026-01-01",
"to_date": "2026-02-15",
},
)
def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace): self.assertEqual(response.status_code, 400)
api_client.force_authenticate(user=owner)
response = api_client.get( def test_persian_this_month_uses_jalali_month_bounds(self):
"/api/reports/chart/", self.client.force_authenticate(user=self.owner)
{
"workspace": str(workspace.id),
"period": "period",
"from_date": "2026-01-01",
"to_date": "2026-02-15",
},
)
assert response.status_code == 400 with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 27),
):
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=self.project,
description="Previous jalali month",
start_time="2026-04-20T08:00:00+03:30",
end_time="2026-04-20T09:00:00+03:30",
duration=timedelta(hours=1),
is_billable=False,
currency="USD",
)
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=self.project,
description="Current jalali month",
start_time="2026-04-21T08:00:00+03:30",
end_time="2026-04-21T10:00:00+03:30",
duration=timedelta(hours=2),
is_billable=False,
currency="USD",
)
response = self.client.get(
"/api/reports/table/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"language": "fa",
},
)
def test_persian_this_month_uses_jalali_month_bounds(api_client, owner, workspace, project, monkeypatch): self.assertEqual(response.status_code, 200)
api_client.force_authenticate(user=owner) self.assertEqual(response.data["summary"]["total_duration"], "02:00:00")
monkeypatch.setattr("apps.reports.services.aggregation.timezone.localdate", lambda: date(2026, 4, 27)) self.assertEqual(response.data["scope"]["from_date"], "2026-04-21")
TimeEntry.objects.create( def test_table_report_cache_stays_until_time_entry_invalidation(self):
workspace=workspace, self.client.force_authenticate(user=self.owner)
user=owner, url = "/api/reports/table/"
project=project, params = {"workspace": str(self.workspace.id), "period": "this_month"}
description="Previous jalali month",
start_time="2026-04-20T08:00:00+03:30",
end_time="2026-04-20T09:00:00+03:30",
duration=timedelta(hours=1),
is_billable=False,
currency="USD",
)
TimeEntry.objects.create(
workspace=workspace,
user=owner,
project=project,
description="Current jalali month",
start_time="2026-04-21T08:00:00+03:30",
end_time="2026-04-21T10:00:00+03:30",
duration=timedelta(hours=2),
is_billable=False,
currency="USD",
)
response = api_client.get( with patch(
"/api/reports/table/", "apps.reports.services.aggregation.timezone.localdate",
{"workspace": str(workspace.id), "period": "this_month", "language": "fa"}, return_value=date(2026, 4, 20),
) ):
first_response = self.client.get(url, params)
self.assertEqual(first_response.status_code, 200)
self.assertEqual(first_response.data["summary"]["total_duration"], "03:00:00")
assert response.status_code == 200 member_entry = TimeEntry.objects.get(description="Member work")
assert response.data["summary"]["total_duration"] == "02:00:00" TimeEntry.objects.filter(id=member_entry.id).update(duration=timedelta(hours=5))
assert response.data["scope"]["from_date"] == "2026-04-21"
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
cached_response = self.client.get(url, params)
self.assertEqual(cached_response.status_code, 200)
self.assertEqual(cached_response.data["summary"]["total_duration"], "03:00:00")
member_entry.refresh_from_db()
member_entry.description = "Member work updated"
member_entry.save(update_fields=["description"])
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
fresh_response = self.client.get(url, params)
self.assertEqual(fresh_response.status_code, 200)
self.assertEqual(fresh_response.data["summary"]["total_duration"], "07:00:00")

View File

@@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from core.admins.base import BaseAdmin from core.admins.base import BaseAdmin, SoftDeleteListFilter
from apps.tags.models import Tag from apps.tags.models import Tag
@@ -15,6 +15,7 @@ class TagAdmin(BaseAdmin):
) )
list_filter = ( list_filter = (
SoftDeleteListFilter,
"workspace", "workspace",
"is_deleted", "is_deleted",
) )

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,59 @@
from django.test import TestCase
from rest_framework.exceptions import PermissionDenied, ValidationError
from apps.tags.models import Tag
from apps.tags.services.tags import create_tag, update_tag
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
class TagServiceTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(mobile="09120000021", password="secret123")
cls.member = User.objects.create_user(mobile="09120000022", password="secret123")
cls.outsider = User.objects.create_user(mobile="09120000023", password="secret123")
cls.workspace = Workspace.objects.create(name="Tags", owner=cls.owner)
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
def test_create_tag_creates_record_for_workspace_member(self):
tag = create_tag(self.member, self.workspace.id, "Urgent", "#111111")
self.assertEqual(tag.name, "Urgent")
self.assertEqual(tag.color, "#111111")
self.assertEqual(tag.created_by, self.member)
def test_create_tag_rejects_non_member(self):
with self.assertRaises(PermissionDenied):
create_tag(self.outsider, self.workspace.id, "Urgent")
def test_create_tag_rejects_duplicate_name(self):
Tag.objects.create(workspace=self.workspace, name="Urgent")
with self.assertRaises(ValidationError) as exc:
create_tag(self.owner, self.workspace.id, "Urgent")
self.assertIn("name", exc.exception.detail)
def test_update_tag_changes_requested_fields(self):
tag = Tag.objects.create(workspace=self.workspace, name="Urgent", color="#000000")
updated = update_tag(tag, name="Later", color="#222222")
self.assertEqual(updated.name, "Later")
self.assertEqual(updated.color, "#222222")
def test_update_tag_rejects_duplicate_name(self):
Tag.objects.create(workspace=self.workspace, name="Existing")
tag = Tag.objects.create(workspace=self.workspace, name="Urgent")
with self.assertRaises(ValidationError) as exc:
update_tag(tag, name="Existing")
self.assertIn("name", exc.exception.detail)

View File

@@ -0,0 +1,136 @@
from rest_framework.test import APITestCase
from apps.tags.models import Tag
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
class TagViewTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(mobile="09120000031", password="secret123")
cls.admin = User.objects.create_user(mobile="09120000032", password="secret123")
cls.second_admin = User.objects.create_user(mobile="09120000033", password="secret123")
cls.member = User.objects.create_user(mobile="09120000034", password="secret123")
cls.guest = User.objects.create_user(mobile="09120000035", password="secret123")
cls.outsider = User.objects.create_user(mobile="09120000036", password="secret123")
cls.workspace = Workspace.objects.create(name="Tags API", owner=cls.owner)
for user, role in (
(cls.admin, WorkspaceMembership.Role.ADMIN),
(cls.second_admin, WorkspaceMembership.Role.ADMIN),
(cls.member, WorkspaceMembership.Role.MEMBER),
(cls.guest, WorkspaceMembership.Role.GUEST),
):
WorkspaceMembership.objects.create(
workspace=cls.workspace,
user=user,
role=role,
is_active=True,
)
cls.other_workspace = Workspace.objects.create(name="Elsewhere", owner=cls.outsider)
cls.visible_tag = Tag.objects.create(workspace=cls.workspace, name="Visible", color="#111111")
cls.hidden_tag = Tag.objects.create(workspace=cls.other_workspace, name="Hidden", color="#222222")
cls.admin_owned_tag = Tag.objects.create(
workspace=cls.workspace,
name="Admin Tag",
color="#333333",
created_by=cls.admin,
updated_by=cls.admin,
)
def test_list_only_returns_tags_for_member_workspaces(self):
self.client.force_authenticate(user=self.member)
response = self.client.get("/api/tags/")
self.assertEqual(response.status_code, 200)
results = (
response.data
if isinstance(response.data, list)
else response.data.get("results")
or response.data.get("items")
or []
)
names = {item["name"] for item in results}
self.assertIn("Visible", names)
self.assertNotIn("Hidden", names)
def test_member_can_create_tag(self):
self.client.force_authenticate(user=self.member)
response = self.client.post(
"/api/tags/",
{
"workspace_id": str(self.workspace.id),
"name": "Created",
"color": "#123456",
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["name"], "Created")
def test_guest_cannot_create_tag(self):
self.client.force_authenticate(user=self.guest)
response = self.client.post(
"/api/tags/",
{
"workspace_id": str(self.workspace.id),
"name": "Created",
},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_member_cannot_update_tag(self):
self.client.force_authenticate(user=self.member)
response = self.client.patch(
f"/api/tags/{self.visible_tag.id}/",
{"name": "Updated"},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_admin_can_update_tag(self):
self.client.force_authenticate(user=self.admin)
response = self.client.patch(
f"/api/tags/{self.visible_tag.id}/",
{"name": "Updated"},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["name"], "Updated")
def test_admin_can_delete_only_tag_they_created(self):
self.client.force_authenticate(user=self.second_admin)
forbidden = self.client.delete(f"/api/tags/{self.admin_owned_tag.id}/")
self.assertEqual(forbidden.status_code, 403)
self.client.force_authenticate(user=self.admin)
allowed = self.client.delete(f"/api/tags/{self.admin_owned_tag.id}/")
self.assertEqual(allowed.status_code, 204)
self.assertTrue(Tag.all_objects.get(id=self.admin_owned_tag.id).is_deleted)
def test_owner_can_delete_any_tag(self):
tag = Tag.objects.create(
workspace=self.workspace,
name="Owner Delete",
created_by=self.admin,
updated_by=self.admin,
)
self.client.force_authenticate(user=self.owner)
response = self.client.delete(f"/api/tags/{tag.id}/")
self.assertEqual(response.status_code, 204)
self.assertTrue(Tag.all_objects.get(id=tag.id).is_deleted)

View File

@@ -1,8 +1,10 @@
from rest_framework import serializers from rest_framework import serializers
from django.utils import timezone
from core.serializers.base import BaseModelSerializer from core.serializers.base import BaseModelSerializer
from apps.time_entries.models import TimeEntry from apps.time_entries.models import TimeEntry
from apps.projects.models import Project from apps.projects.models import Project
from apps.projects.services.access import ensure_project_access
from apps.tags.models import Tag from apps.tags.models import Tag
@@ -30,6 +32,27 @@ class TimeEntrySerializer(BaseModelSerializer):
tag_details = serializers.SerializerMethodField() tag_details = serializers.SerializerMethodField()
start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S") start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S")
end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True) end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True)
start_time_ms = serializers.SerializerMethodField()
end_time_ms = serializers.SerializerMethodField()
server_now_ms = serializers.SerializerMethodField()
@staticmethod
def _epoch_ms(value):
if value is None:
return None
if timezone.is_naive(value):
value = timezone.make_aware(value, timezone.get_current_timezone())
return int(value.timestamp() * 1000)
def get_start_time_ms(self, obj):
return self._epoch_ms(obj.start_time)
def get_end_time_ms(self, obj):
return self._epoch_ms(obj.end_time)
def get_server_now_ms(self, obj):
server_now = self.context.get("server_now") or timezone.now()
return self._epoch_ms(server_now)
def get_tags(self, obj): def get_tags(self, obj):
return [str(tag.id) for tag in Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")] return [str(tag.id) for tag in Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")]
@@ -75,7 +98,10 @@ class TimeEntrySerializer(BaseModelSerializer):
"project_details", "project_details",
"description", "description",
"start_time", "start_time",
"start_time_ms",
"end_time", "end_time",
"end_time_ms",
"server_now_ms",
"duration", "duration",
"tags", "tags",
"tag_details", "tag_details",
@@ -92,13 +118,21 @@ class TimeEntryCreateSerializer(serializers.Serializer):
""" """
workspace_id = serializers.UUIDField() workspace_id = serializers.UUIDField()
project_id = serializers.UUIDField(required=False, allow_null=True) project_id = serializers.UUIDField(required=False, allow_null=True)
start_time = serializers.DateTimeField() start_time = serializers.DateTimeField(required=False)
end_time = serializers.DateTimeField(required=False, allow_null=True) end_time = serializers.DateTimeField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True, default="") description = serializers.CharField(required=False, allow_blank=True, default="")
tags = serializers.ListField(child=serializers.UUIDField(), required=False) tags = serializers.ListField(child=serializers.UUIDField(), required=False)
is_billable = serializers.BooleanField(default=False) is_billable = serializers.BooleanField(default=False)
def validate(self, attrs): def validate(self, attrs):
user = self.context.get("request").user if self.context.get("request") else None
workspace_id = attrs.get("workspace_id")
start_time = attrs.get("start_time")
end_time = attrs.get("end_time")
if end_time is not None and start_time is None:
raise serializers.ValidationError({"start_time": "Start time is required when end time is provided."})
project_id = attrs.pop("project_id", serializers.empty) project_id = attrs.pop("project_id", serializers.empty)
if project_id is not serializers.empty: if project_id is not serializers.empty:
if project_id is None: if project_id is None:
@@ -107,6 +141,10 @@ class TimeEntryCreateSerializer(serializers.Serializer):
project = Project.objects.filter(id=project_id).first() project = Project.objects.filter(id=project_id).first()
if not project: if not project:
raise serializers.ValidationError({"project_id": "Selected project is unavailable."}) raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
if workspace_id and str(project.workspace_id) != str(workspace_id):
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
if user:
ensure_project_access(user, project)
attrs["project"] = project attrs["project"] = project
tag_ids = attrs.pop("tags", serializers.empty) tag_ids = attrs.pop("tags", serializers.empty)
@@ -134,6 +172,7 @@ class TimeEntryUpdateSerializer(serializers.Serializer):
def validate(self, attrs): def validate(self, attrs):
entry = self.instance entry = self.instance
user = self.context.get("request").user if self.context.get("request") else None
project_id = attrs.pop("project_id", serializers.empty) project_id = attrs.pop("project_id", serializers.empty)
if project_id is not serializers.empty: if project_id is not serializers.empty:
@@ -146,6 +185,10 @@ class TimeEntryUpdateSerializer(serializers.Serializer):
project = Project.objects.filter(id=project_id).first() project = Project.objects.filter(id=project_id).first()
if not project: if not project:
raise serializers.ValidationError({"project_id": "Selected project is unavailable."}) raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
if entry and str(project.workspace_id) != str(entry.workspace_id):
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
if user:
ensure_project_access(user, project)
attrs["project"] = project attrs["project"] = project
tag_ids = attrs.pop("tags", serializers.empty) tag_ids = attrs.pop("tags", serializers.empty)

View File

@@ -38,6 +38,17 @@ class TimeEntryViewSet(ModelViewSet):
filterset_class = TimeEntryFilter filterset_class = TimeEntryFilter
search_fields = ["description", "project__name", "project__client__name", "tags__name"] search_fields = ["description", "project__name", "project__client__name", "tags__name"]
@staticmethod
def _epoch_ms(value):
if timezone.is_naive(value):
value = timezone.make_aware(value, timezone.get_current_timezone())
return int(value.timestamp() * 1000)
def _serializer_context(self, *, server_now=None):
context = self.get_serializer_context()
context["server_now"] = server_now or timezone.now()
return context
@staticmethod @staticmethod
def _serialize_duration_ms(entry): def _serialize_duration_ms(entry):
if entry.duration is not None: if entry.duration is not None:
@@ -51,8 +62,12 @@ class TimeEntryViewSet(ModelViewSet):
days_since_sunday = (local_dt.weekday() + 1) % 7 days_since_sunday = (local_dt.weekday() + 1) % 7
return (local_dt - timedelta(days=days_since_sunday)).date() return (local_dt - timedelta(days=days_since_sunday)).date()
def _build_grouped_entries(self, entries): def _build_grouped_entries(self, entries, *, server_now):
serialized_entries = TimeEntrySerializer(entries, many=True, context=self.get_serializer_context()).data serialized_entries = TimeEntrySerializer(
entries,
many=True,
context=self._serializer_context(server_now=server_now),
).data
serialized_by_id = {item["id"]: item for item in serialized_entries} serialized_by_id = {item["id"]: item for item in serialized_entries}
weeks = [] weeks = []
weeks_by_key = {} weeks_by_key = {}
@@ -114,6 +129,7 @@ class TimeEntryViewSet(ModelViewSet):
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
paginator = self.pagination_class() paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset, request, view=self) page = paginator.paginate_queryset(queryset, request, view=self)
server_now = timezone.now()
current_items_count = len(page) current_items_count = len(page)
has_more = (paginator.offset + current_items_count) < paginator.count has_more = (paginator.offset + current_items_count) < paginator.count
@@ -125,7 +141,19 @@ class TimeEntryViewSet(ModelViewSet):
"offset": paginator.offset, "offset": paginator.offset,
"next_offset": paginator.offset + current_items_count if has_more else None, "next_offset": paginator.offset + current_items_count if has_more else None,
"has_more": has_more, "has_more": has_more,
"groups": self._build_grouped_entries(page), "server_now_ms": self._epoch_ms(server_now),
"server_now": server_now.isoformat(),
"groups": self._build_grouped_entries(page, server_now=server_now),
}
)
@action(detail=False, methods=["get"], url_path="debug-time")
def debug_time(self, request):
server_now = timezone.now()
return Response(
{
"server_now_ms": self._epoch_ms(server_now),
"server_now": server_now.isoformat(),
} }
) )
@@ -148,7 +176,7 @@ class TimeEntryViewSet(ModelViewSet):
**serializer.validated_data **serializer.validated_data
) )
output_serializer = TimeEntrySerializer(entry, context=self.get_serializer_context()) output_serializer = TimeEntrySerializer(entry, context=self._serializer_context())
return Response(output_serializer.data, status=status.HTTP_201_CREATED) return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
@@ -168,7 +196,7 @@ class TimeEntryViewSet(ModelViewSet):
**serializer.validated_data **serializer.validated_data
) )
output_serializer = TimeEntrySerializer(updated_entry, context=self.get_serializer_context()) output_serializer = TimeEntrySerializer(updated_entry, context=self._serializer_context())
return Response(output_serializer.data, status=status.HTTP_200_OK) return Response(output_serializer.data, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"]) @action(detail=True, methods=["post"])
@@ -189,7 +217,7 @@ class TimeEntryViewSet(ModelViewSet):
end_time = serializer.validated_data.get("end_time") end_time = serializer.validated_data.get("end_time")
stopped_entry = stop_time_entry(entry, end_time=end_time) stopped_entry = stop_time_entry(entry, end_time=end_time)
output_serializer = TimeEntrySerializer(stopped_entry, context=self.get_serializer_context()) output_serializer = TimeEntrySerializer(stopped_entry, context=self._serializer_context())
return Response(output_serializer.data, status=status.HTTP_200_OK) return Response(output_serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.12 on 2026-04-30 12:23
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0003_project_project_ws_arch_upd_idx'),
('tags', '0001_initial'),
('time_entries', '0001_initial'),
('workspaces', '0007_workspacemembership_membership_ws_active_user_idx'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='timeentry',
index=models.Index(fields=['workspace', 'user', 'start_time'], name='time_entry_ws_user_start_idx'),
),
]

View File

@@ -62,6 +62,7 @@ class TimeEntry(BaseModel):
models.Index(fields=["project"], name="time_entry_project_idx"), models.Index(fields=["project"], name="time_entry_project_idx"),
models.Index(fields=["start_time"], name="time_entry_start_idx"), models.Index(fields=["start_time"], name="time_entry_start_idx"),
models.Index(fields=["workspace", "start_time"], name="time_entry_workspace_start_idx"), models.Index(fields=["workspace", "start_time"], name="time_entry_workspace_start_idx"),
models.Index(fields=["workspace", "user", "start_time"], name="time_entry_ws_user_start_idx"),
] ]
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(

View File

@@ -1,7 +1,14 @@
from apps.projects.services.access import user_has_project_access
from apps.projects.services.rates import get_current_project_user_rate
from apps.workspaces.models import WorkspaceUserRate from apps.workspaces.models import WorkspaceUserRate
def resolve_rate(user, project): def resolve_rate(user, project):
if user_has_project_access(user, project):
project_user_rate = get_current_project_user_rate(project=project, user=user)
if project_user_rate:
return project_user_rate.hourly_rate, project_user_rate.currency
workspace_user_rate = WorkspaceUserRate.objects.filter( workspace_user_rate = WorkspaceUserRate.objects.filter(
user=user, user=user,
workspace=project.workspace, workspace=project.workspace,

View File

@@ -2,6 +2,7 @@
from django.utils import timezone from django.utils import timezone
from rest_framework.exceptions import ValidationError, PermissionDenied from rest_framework.exceptions import ValidationError, PermissionDenied
from apps.projects.services.access import user_has_project_access
from apps.time_entries.models import TimeEntry from apps.time_entries.models import TimeEntry
from apps.time_entries.services.rates import resolve_rate from apps.time_entries.services.rates import resolve_rate
from apps.workspaces.models import Workspace from apps.workspaces.models import Workspace
@@ -21,7 +22,7 @@ def _verify_workspace_access(user, workspace_id):
raise PermissionDenied("You do not have access to this workspace.") raise PermissionDenied("You do not have access to this workspace.")
def create_time_entry(user, workspace_id, start_time, end_time=None, project=None, tags=None, description="", is_billable=False): def create_time_entry(user, workspace_id, start_time=None, end_time=None, project=None, tags=None, description="", is_billable=False):
""" """
Creates a new time entry. If end_time is None, it acts as a running timer. Creates a new time entry. If end_time is None, it acts as a running timer.
""" """
@@ -37,11 +38,18 @@ def create_time_entry(user, workspace_id, start_time, end_time=None, project=Non
if has_running_timer: if has_running_timer:
raise ValidationError({"non_field_errors": "You already have a running timer in this workspace."}) raise ValidationError({"non_field_errors": "You already have a running timer in this workspace."})
if start_time is None:
if end_time is not None:
raise ValidationError({"start_time": "Start time is required when end time is provided."})
start_time = timezone.now()
if start_time and end_time and start_time >= end_time: if start_time and end_time and start_time >= end_time:
raise ValidationError({"end_time": "End time must be strictly after start time."}) raise ValidationError({"end_time": "End time must be strictly after start time."})
if project and project.workspace_id != workspace_id: if project and project.workspace_id != workspace_id:
raise ValidationError({"project": "Project must belong to the same workspace."}) raise ValidationError({"project": "Project must belong to the same workspace."})
if project and not user_has_project_access(user, project):
raise ValidationError({"project_id": "Selected project is unavailable."})
duration = (end_time - start_time) if end_time else None duration = (end_time - start_time) if end_time else None
@@ -79,6 +87,8 @@ def update_time_entry(entry, **kwargs):
project = kwargs.get("project", entry.project) project = kwargs.get("project", entry.project)
if project and project.workspace_id != entry.workspace_id: if project and project.workspace_id != entry.workspace_id:
raise ValidationError({"project": "Project must belong to the same workspace."}) raise ValidationError({"project": "Project must belong to the same workspace."})
if project and not user_has_project_access(entry.user, project):
raise ValidationError({"project_id": "Selected project is unavailable."})
start_time = kwargs.get("start_time", entry.start_time) start_time = kwargs.get("start_time", entry.start_time)
end_time = kwargs.get("end_time", entry.end_time) end_time = kwargs.get("end_time", entry.end_time)

View File

@@ -0,0 +1 @@

View File

@@ -1,5 +1,7 @@
from datetime import datetime from datetime import datetime
from django.test import TestCase
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import Project from apps.projects.models import Project
from apps.tags.models import Tag from apps.tags.models import Tag
@@ -12,78 +14,94 @@ from apps.workspaces.models import Workspace
def make_aware(year, month, day, hour=9, minute=0, second=0): def make_aware(year, month, day, hour=9, minute=0, second=0):
from django.utils import timezone from django.utils import timezone
return timezone.make_aware(datetime(year, month, day, hour, minute, second), timezone.get_current_timezone()) current_timezone = timezone.get_current_timezone()
return timezone.make_aware(
datetime(year, month, day, hour, minute, second),
def test_time_entry_filter_supports_project_client_tags_and_custom_dates(db): current_timezone,
user = User.objects.create_user(mobile="09124444444", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
client_a = Client.objects.create(workspace=workspace, name="Client A")
client_b = Client.objects.create(workspace=workspace, name="Client B")
project_a = Project.objects.create(workspace=workspace, client=client_a, name="Project A")
project_b = Project.objects.create(workspace=workspace, client=client_b, name="Project B")
tag_backend = Tag.objects.create(workspace=workspace, name="Backend", color="#0EA5E9")
tag_ops = Tag.objects.create(workspace=workspace, name="Ops", color="#10B981")
entry_a = TimeEntry.objects.create(
workspace=workspace,
user=user,
project=project_a,
description="Backend work",
start_time=make_aware(2026, 4, 10, 10, 0, 0),
end_time=make_aware(2026, 4, 10, 12, 0, 0),
)
entry_a.tags.set([tag_backend])
entry_b = TimeEntry.objects.create(
workspace=workspace,
user=user,
project=project_b,
description="Ops work",
start_time=make_aware(2026, 4, 18, 14, 0, 0),
end_time=make_aware(2026, 4, 18, 15, 30, 0),
)
entry_b.tags.set([tag_ops])
queryset = TimeEntry.objects.filter(workspace=workspace, is_deleted=False)
filtered = TimeEntryFilter(
data={
"workspace": str(workspace.id),
"project": str(project_a.id),
"client": str(client_a.id),
"tags": str(tag_backend.id),
"started_after": "2026-04-01",
"started_before": "2026-04-15",
},
queryset=queryset,
).qs
assert list(filtered) == [entry_a]
def test_time_entry_filter_supports_status_values(db):
user = User.objects.create_user(mobile="09125555555", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
ended_entry = TimeEntry.objects.create(
workspace=workspace,
user=user,
description="Ended entry",
start_time=make_aware(2026, 4, 24, 9, 0, 0),
end_time=make_aware(2026, 4, 24, 10, 0, 0),
)
running_entry = TimeEntry.objects.create(
workspace=workspace,
user=user,
description="Running entry",
start_time=make_aware(2026, 4, 15, 9, 0, 0),
) )
queryset = TimeEntry.objects.filter(workspace=workspace, is_deleted=False)
ended = TimeEntryFilter(data={"status": "ended"}, queryset=queryset).qs class TimeEntryFilterTests(TestCase):
running = TimeEntryFilter(data={"status": "running"}, queryset=queryset).qs def test_time_entry_filter_supports_project_client_tags_and_custom_dates(self):
user = User.objects.create_user(mobile="09124444444", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
client_a = Client.objects.create(workspace=workspace, name="Client A")
client_b = Client.objects.create(workspace=workspace, name="Client B")
project_a = Project.objects.create(
workspace=workspace,
client=client_a,
name="Project A",
)
project_b = Project.objects.create(
workspace=workspace,
client=client_b,
name="Project B",
)
tag_backend = Tag.objects.create(
workspace=workspace,
name="Backend",
color="#0EA5E9",
)
tag_ops = Tag.objects.create(workspace=workspace, name="Ops", color="#10B981")
assert list(ended) == [ended_entry] entry_a = TimeEntry.objects.create(
assert list(running) == [running_entry] workspace=workspace,
user=user,
project=project_a,
description="Backend work",
start_time=make_aware(2026, 4, 10, 10, 0, 0),
end_time=make_aware(2026, 4, 10, 12, 0, 0),
)
entry_a.tags.set([tag_backend])
entry_b = TimeEntry.objects.create(
workspace=workspace,
user=user,
project=project_b,
description="Ops work",
start_time=make_aware(2026, 4, 18, 14, 0, 0),
end_time=make_aware(2026, 4, 18, 15, 30, 0),
)
entry_b.tags.set([tag_ops])
queryset = TimeEntry.objects.filter(workspace=workspace, is_deleted=False)
filtered = TimeEntryFilter(
data={
"workspace": str(workspace.id),
"project": str(project_a.id),
"client": str(client_a.id),
"tags": str(tag_backend.id),
"started_after": "2026-04-01",
"started_before": "2026-04-15",
},
queryset=queryset,
).qs
self.assertEqual(list(filtered), [entry_a])
def test_time_entry_filter_supports_status_values(self):
user = User.objects.create_user(mobile="09125555555", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
ended_entry = TimeEntry.objects.create(
workspace=workspace,
user=user,
description="Ended entry",
start_time=make_aware(2026, 4, 24, 9, 0, 0),
end_time=make_aware(2026, 4, 24, 10, 0, 0),
)
running_entry = TimeEntry.objects.create(
workspace=workspace,
user=user,
description="Running entry",
start_time=make_aware(2026, 4, 15, 9, 0, 0),
)
queryset = TimeEntry.objects.filter(workspace=workspace, is_deleted=False)
ended = TimeEntryFilter(data={"status": "ended"}, queryset=queryset).qs
running = TimeEntryFilter(data={"status": "running"}, queryset=queryset).qs
self.assertEqual(list(ended), [ended_entry])
self.assertEqual(list(running), [running_entry])

View File

@@ -1,59 +1,66 @@
from datetime import datetime from datetime import datetime
from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from apps.time_entries.api.serializers import TimeEntrySerializer
from apps.time_entries.models import TimeEntry
from apps.projects.models import Project from apps.projects.models import Project
from apps.tags.models import Tag from apps.tags.models import Tag
from apps.time_entries.api.serializers import TimeEntrySerializer
from apps.time_entries.models import TimeEntry
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace from apps.workspaces.models import Workspace
def test_time_entry_serializer_keeps_seconds(db): class TimeEntrySerializerTests(TestCase):
user = User.objects.create_user(mobile="09123333333", password="secret123") def test_time_entry_serializer_keeps_seconds(self):
workspace = Workspace.objects.create(name="Core", owner=user) user = User.objects.create_user(mobile="09123333333", password="secret123")
current_timezone = timezone.get_current_timezone() workspace = Workspace.objects.create(name="Core", owner=user)
current_timezone = timezone.get_current_timezone()
start_time = timezone.make_aware(datetime(2026, 4, 23, 10, 15, 42), current_timezone) start_time = timezone.make_aware(
end_time = timezone.make_aware(datetime(2026, 4, 23, 11, 0, 5), current_timezone) datetime(2026, 4, 23, 10, 15, 42),
current_timezone,
)
end_time = timezone.make_aware(
datetime(2026, 4, 23, 11, 0, 5),
current_timezone,
)
entry = TimeEntry.objects.create( entry = TimeEntry.objects.create(
workspace=workspace, workspace=workspace,
user=user, user=user,
start_time=start_time, start_time=start_time,
end_time=end_time, end_time=end_time,
) )
data = TimeEntrySerializer(entry).data data = TimeEntrySerializer(entry).data
assert data["start_time"] == start_time.strftime("%Y-%m-%d %H:%M:%S") self.assertEqual(data["start_time"], start_time.strftime("%Y-%m-%d %H:%M:%S"))
assert data["end_time"] == end_time.strftime("%Y-%m-%d %H:%M:%S") self.assertEqual(data["end_time"], end_time.strftime("%Y-%m-%d %H:%M:%S"))
def test_time_entry_serializer_includes_deleted_project_and_tags(self):
user = User.objects.create_user(mobile="09124444444", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
project = Project.objects.create(workspace=workspace, name="Legacy Project")
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#334155")
project.delete()
tag.delete()
def test_time_entry_serializer_includes_deleted_project_and_tags(db): entry = TimeEntry.objects.create(
user = User.objects.create_user(mobile="09124444444", password="secret123") workspace=workspace,
workspace = Workspace.objects.create(name="Core", owner=user) user=user,
project = Project.objects.create(workspace=workspace, name="Legacy Project") project=Project.all_objects.get(id=project.id),
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#334155") description="Historical work",
project.delete() start_time=timezone.now(),
tag.delete() end_time=timezone.now(),
)
entry.tags.set([Tag.all_objects.get(id=tag.id)])
entry = TimeEntry.objects.create( data = TimeEntrySerializer(entry).data
workspace=workspace,
user=user,
project=Project.all_objects.get(id=project.id),
description="Historical work",
start_time=timezone.now(),
end_time=timezone.now(),
)
entry.tags.set([Tag.all_objects.get(id=tag.id)])
data = TimeEntrySerializer(entry).data self.assertEqual(data["project"], str(project.id))
self.assertEqual(data["project_details"]["name"], "Legacy Project")
assert data["project"] == str(project.id) self.assertTrue(data["project_details"]["is_deleted"])
assert data["project_details"]["name"] == "Legacy Project" self.assertEqual(data["tags"], [str(tag.id)])
assert data["project_details"]["is_deleted"] is True self.assertEqual(data["tag_details"][0]["name"], "Legacy Tag")
assert data["tags"] == [str(tag.id)] self.assertTrue(data["tag_details"][0]["is_deleted"])
assert data["tag_details"][0]["name"] == "Legacy Tag"
assert data["tag_details"][0]["is_deleted"] is True

View File

@@ -1,78 +1,140 @@
from datetime import timedelta from datetime import timedelta
from decimal import Decimal
import pytest from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from apps.projects.models import Project from apps.projects.models import Project, ProjectAccess, ProjectUserRate
from apps.tags.models import Tag from apps.tags.models import Tag
from apps.time_entries.services.time_entries import create_time_entry, stop_time_entry, update_time_entry from apps.time_entries.services.time_entries import (
create_time_entry,
stop_time_entry,
update_time_entry,
)
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
@pytest.fixture class TimeEntryServiceTests(TestCase):
def workspace_owner(db): @classmethod
user = User.objects.create_user(mobile="09121111111", password="secret123") def setUpTestData(cls):
workspace = Workspace.objects.create(name="Core", owner=user) cls.user = User.objects.create_user(mobile="09121111111", password="secret123")
return user, workspace cls.member = User.objects.create_user(mobile="09121111112", password="secret123")
cls.workspace = Workspace.objects.create(name="Core", owner=cls.user)
WorkspaceMembership.objects.create(
def test_create_time_entry_allows_only_one_running_timer_per_workspace(workspace_owner): workspace=cls.workspace,
user, workspace = workspace_owner user=cls.member,
role=WorkspaceMembership.Role.MEMBER,
create_time_entry( is_active=True,
user=user,
workspace_id=workspace.id,
start_time=timezone.now(),
)
with pytest.raises(ValidationError):
create_time_entry(
user=user,
workspace_id=workspace.id,
start_time=timezone.now() + timedelta(minutes=5),
) )
def test_create_time_entry_allows_only_one_running_timer_per_workspace(self):
create_time_entry(
user=self.user,
workspace_id=self.workspace.id,
start_time=timezone.now(),
)
def test_stop_time_entry_sets_end_time_and_duration(workspace_owner): with self.assertRaises(ValidationError):
user, workspace = workspace_owner create_time_entry(
entry = create_time_entry( user=self.user,
user=user, workspace_id=self.workspace.id,
workspace_id=workspace.id, start_time=timezone.now() + timedelta(minutes=5),
start_time=timezone.now() - timedelta(hours=1), )
)
stopped_entry = stop_time_entry(entry, end_time=timezone.now()) def test_stop_time_entry_sets_end_time_and_duration(self):
entry = create_time_entry(
user=self.user,
workspace_id=self.workspace.id,
start_time=timezone.now() - timedelta(hours=1),
)
assert stopped_entry.end_time is not None stopped_entry = stop_time_entry(entry, end_time=timezone.now())
assert stopped_entry.duration is not None
self.assertIsNotNone(stopped_entry.end_time)
self.assertIsNotNone(stopped_entry.duration)
def test_update_time_entry_preserves_deleted_project_and_tags(workspace_owner): def test_create_running_time_entry_defaults_start_time_to_server_now(self):
user, workspace = workspace_owner before = timezone.now()
project = Project.objects.create(workspace=workspace, name="Deleted project") entry = create_time_entry(
tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#0f172a") user=self.user,
entry = create_time_entry( workspace_id=self.workspace.id,
user=user, )
workspace_id=workspace.id, after = timezone.now()
start_time=timezone.now() - timedelta(hours=1),
end_time=timezone.now(),
project=project,
tags=[tag],
description="Before delete",
)
project.delete() self.assertIsNone(entry.end_time)
tag.delete() self.assertGreaterEqual(entry.start_time, before)
self.assertLessEqual(entry.start_time, after)
updated_entry = update_time_entry( def test_update_time_entry_preserves_deleted_project_and_tags(self):
entry, project = Project.objects.create(workspace=self.workspace, name="Deleted project")
project=Project.all_objects.get(id=project.id), tag = Tag.objects.create(
tags=[Tag.all_objects.get(id=tag.id)], workspace=self.workspace,
description="After delete", name="Deleted tag",
) color="#0f172a",
)
entry = create_time_entry(
user=self.user,
workspace_id=self.workspace.id,
start_time=timezone.now() - timedelta(hours=1),
end_time=timezone.now(),
project=project,
tags=[tag],
description="Before delete",
)
assert updated_entry.description == "After delete" project.delete()
assert updated_entry.project_id == project.id tag.delete()
assert list(Tag.all_objects.filter(time_entries=updated_entry).values_list("id", flat=True)) == [tag.id]
updated_entry = update_time_entry(
entry,
project=Project.all_objects.get(id=project.id),
tags=[Tag.all_objects.get(id=tag.id)],
description="After delete",
)
self.assertEqual(updated_entry.description, "After delete")
self.assertEqual(updated_entry.project_id, project.id)
self.assertEqual(
list(
Tag.all_objects.filter(time_entries=updated_entry).values_list(
"id",
flat=True,
)
),
[tag.id],
)
def test_create_billable_time_entry_uses_project_user_rate_override(self):
project = Project.objects.create(workspace=self.workspace, name="Override project")
ProjectAccess.objects.create(project=project, user=self.member)
WorkspaceUserRate.objects.create(
workspace=self.workspace,
user=self.member,
hourly_rate=Decimal("10.00"),
currency="USD",
effective_from=self.workspace.created_at,
is_active=True,
)
ProjectUserRate.objects.create(
project=project,
user=self.member,
hourly_rate=Decimal("20.00"),
currency="EUR",
effective_from=self.workspace.created_at,
is_active=True,
)
entry = create_time_entry(
user=self.member,
workspace_id=self.workspace.id,
start_time=timezone.now() - timedelta(minutes=30),
end_time=timezone.now(),
project=project,
description="Billable work",
is_billable=True,
)
self.assertEqual(entry.hourly_rate, Decimal("20.00"))
self.assertEqual(entry.currency, "EUR")

View File

@@ -1,140 +1,263 @@
from datetime import datetime from datetime import datetime, timedelta
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APITestCase
from apps.projects.models import Project, ProjectAccess
from apps.tags.models import Tag from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry from apps.time_entries.models import TimeEntry
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace from apps.workspaces.models import Workspace, WorkspaceMembership
def make_aware(year, month, day, hour=9, minute=0, second=0): def make_aware(year, month, day, hour=9, minute=0, second=0):
return timezone.make_aware(datetime(year, month, day, hour, minute, second), timezone.get_current_timezone()) current_timezone = timezone.get_current_timezone()
return timezone.make_aware(
datetime(year, month, day, hour, minute, second),
def test_time_entry_list_returns_grouped_payload_for_ended_entries(db): current_timezone,
user = User.objects.create_user(mobile="09126666666", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
first_entry = TimeEntry.objects.create(
workspace=workspace,
user=user,
description="Morning work",
start_time=make_aware(2026, 4, 24, 9, 0, 0),
end_time=make_aware(2026, 4, 24, 10, 30, 0),
)
TimeEntry.objects.create(
workspace=workspace,
user=user,
description="Running work",
start_time=make_aware(2026, 4, 24, 11, 0, 0),
) )
client = APIClient()
client.force_authenticate(user=user)
response = client.get( class TimeEntryViewTests(APITestCase):
"/api/time-entries/", def test_create_running_time_entry_without_start_time_uses_server_time(self):
{ user = User.objects.create_user(mobile="09125555555", password="secret123")
"workspace": str(workspace.id), workspace = Workspace.objects.create(name="Core", owner=user)
"status": "ended",
"limit": 10,
"offset": 0,
},
)
assert response.status_code == 200 self.client.force_authenticate(user=user)
assert response.data["current_page_items_count"] == 1 before = timezone.now()
assert response.data["has_more"] is False response = self.client.post(
assert len(response.data["groups"]) == 1 "/api/time-entries/",
assert len(response.data["groups"][0]["days"]) == 1 {
assert response.data["groups"][0]["days"][0]["entries"][0]["id"] == str(first_entry.id) "workspace_id": str(workspace.id),
"description": "Running work",
},
format="json",
)
after = timezone.now()
self.assertEqual(response.status_code, 201)
entry = TimeEntry.objects.get(id=response.data["id"])
self.assertIsNone(entry.end_time)
self.assertGreaterEqual(entry.start_time, before)
self.assertLessEqual(entry.start_time, after)
self.assertIsInstance(response.data["start_time_ms"], int)
self.assertIsNone(response.data["end_time_ms"])
self.assertIsInstance(response.data["server_now_ms"], int)
def test_time_entry_update_preserves_current_deleted_tags(db): def test_time_entry_list_returns_grouped_payload_for_ended_entries(self):
user = User.objects.create_user(mobile="09127777777", password="secret123") user = User.objects.create_user(mobile="09126666666", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user) workspace = Workspace.objects.create(name="Core", owner=user)
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#475569")
entry = TimeEntry.objects.create(
workspace=workspace,
user=user,
description="Old",
start_time=make_aware(2026, 4, 24, 9, 0, 0),
end_time=make_aware(2026, 4, 24, 10, 30, 0),
)
entry.tags.set([tag])
tag.delete()
client = APIClient() first_entry = TimeEntry.objects.create(
client.force_authenticate(user=user) workspace=workspace,
user=user,
description="Morning work",
start_time=make_aware(2026, 4, 24, 9, 0, 0),
end_time=make_aware(2026, 4, 24, 10, 30, 0),
)
TimeEntry.objects.create(
workspace=workspace,
user=user,
description="Running work",
start_time=make_aware(2026, 4, 24, 11, 0, 0),
)
response = client.patch( self.client.force_authenticate(user=user)
f"/api/time-entries/{entry.id}/", response = self.client.get(
{ "/api/time-entries/",
"description": "Still editable", {
"tags": [str(tag.id)], "workspace": str(workspace.id),
}, "status": "ended",
format="json", "limit": 10,
) "offset": 0,
},
)
assert response.status_code == 200 self.assertEqual(response.status_code, 200)
assert response.data["description"] == "Still editable" self.assertEqual(response.data["current_page_items_count"], 1)
assert response.data["tag_details"][0]["is_deleted"] is True self.assertIsInstance(response.data["server_now_ms"], int)
self.assertIn("server_now", response.data)
self.assertFalse(response.data["has_more"])
self.assertEqual(len(response.data["groups"]), 1)
self.assertEqual(len(response.data["groups"][0]["days"]), 1)
self.assertEqual(
response.data["groups"][0]["days"][0]["entries"][0]["id"],
str(first_entry.id),
)
entry_payload = response.data["groups"][0]["days"][0]["entries"][0]
self.assertIsInstance(entry_payload["start_time_ms"], int)
self.assertIsInstance(entry_payload["end_time_ms"], int)
self.assertIsInstance(entry_payload["server_now_ms"], int)
def test_debug_time_returns_server_clock_payload(self):
user = User.objects.create_user(mobile="09126666667", password="secret123")
self.client.force_authenticate(user=user)
def test_time_entry_update_rejects_new_deleted_tag_attachment(db): response = self.client.get("/api/time-entries/debug-time/")
user = User.objects.create_user(mobile="09128888888", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
deleted_tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#475569")
deleted_tag.delete()
entry = TimeEntry.objects.create(
workspace=workspace,
user=user,
description="Entry",
start_time=make_aware(2026, 4, 24, 9, 0, 0),
end_time=make_aware(2026, 4, 24, 10, 30, 0),
)
client = APIClient() self.assertEqual(response.status_code, 200)
client.force_authenticate(user=user) self.assertIsInstance(response.data["server_now_ms"], int)
self.assertIn("server_now", response.data)
response = client.patch( def test_stop_running_time_entry_returns_server_epoch_fields(self):
f"/api/time-entries/{entry.id}/", user = User.objects.create_user(mobile="09126666668", password="secret123")
{ workspace = Workspace.objects.create(name="Core", owner=user)
"tags": [str(deleted_tag.id)], entry = TimeEntry.objects.create(
}, workspace=workspace,
format="json", user=user,
) description="Running work",
start_time=timezone.now() - timedelta(seconds=5),
)
assert response.status_code == 400 self.client.force_authenticate(user=user)
assert "unavailable" in response.data["error"].lower() response = self.client.post(f"/api/time-entries/{entry.id}/stop/", {}, format="json")
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.data["start_time_ms"], int)
self.assertIsInstance(response.data["end_time_ms"], int)
self.assertIsInstance(response.data["server_now_ms"], int)
entry.refresh_from_db()
self.assertIsNotNone(entry.duration)
self.assertGreaterEqual(entry.duration.total_seconds(), 5)
def test_time_entry_update_can_remove_current_deleted_tag(db): def test_time_entry_update_preserves_current_deleted_tags(self):
user = User.objects.create_user(mobile="09129999999", password="secret123") user = User.objects.create_user(mobile="09127777777", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user) workspace = Workspace.objects.create(name="Core", owner=user)
deleted_tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#475569") tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#475569")
entry = TimeEntry.objects.create( entry = TimeEntry.objects.create(
workspace=workspace, workspace=workspace,
user=user, user=user,
description="Entry", description="Old",
start_time=make_aware(2026, 4, 24, 9, 0, 0), start_time=make_aware(2026, 4, 24, 9, 0, 0),
end_time=make_aware(2026, 4, 24, 10, 30, 0), end_time=make_aware(2026, 4, 24, 10, 30, 0),
) )
entry.tags.set([deleted_tag]) entry.tags.set([tag])
deleted_tag.delete() tag.delete()
client = APIClient() self.client.force_authenticate(user=user)
client.force_authenticate(user=user) response = self.client.patch(
f"/api/time-entries/{entry.id}/",
{
"description": "Still editable",
"tags": [str(tag.id)],
},
format="json",
)
response = client.patch( self.assertEqual(response.status_code, 200)
f"/api/time-entries/{entry.id}/", self.assertEqual(response.data["description"], "Still editable")
{ self.assertTrue(response.data["tag_details"][0]["is_deleted"])
"tags": [],
},
format="json",
)
assert response.status_code == 200 def test_time_entry_update_rejects_new_deleted_tag_attachment(self):
assert response.data["tags"] == [] user = User.objects.create_user(mobile="09128888888", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
deleted_tag = Tag.objects.create(
workspace=workspace,
name="Deleted tag",
color="#475569",
)
deleted_tag.delete()
entry = TimeEntry.objects.create(
workspace=workspace,
user=user,
description="Entry",
start_time=make_aware(2026, 4, 24, 9, 0, 0),
end_time=make_aware(2026, 4, 24, 10, 30, 0),
)
self.client.force_authenticate(user=user)
response = self.client.patch(
f"/api/time-entries/{entry.id}/",
{"tags": [str(deleted_tag.id)]},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("unavailable", response.data["error"].lower())
def test_time_entry_update_can_remove_current_deleted_tag(self):
user = User.objects.create_user(mobile="09129999999", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
deleted_tag = Tag.objects.create(
workspace=workspace,
name="Deleted tag",
color="#475569",
)
entry = TimeEntry.objects.create(
workspace=workspace,
user=user,
description="Entry",
start_time=make_aware(2026, 4, 24, 9, 0, 0),
end_time=make_aware(2026, 4, 24, 10, 30, 0),
)
entry.tags.set([deleted_tag])
deleted_tag.delete()
self.client.force_authenticate(user=user)
response = self.client.patch(
f"/api/time-entries/{entry.id}/",
{"tags": []},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["tags"], [])
def test_member_cannot_create_time_entry_for_inaccessible_project(self):
owner = User.objects.create_user(mobile="09120000001", password="secret123")
member = User.objects.create_user(mobile="09120000002", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=owner)
WorkspaceMembership.objects.create(
workspace=workspace,
user=member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
project = Project.objects.create(workspace=workspace, name="Restricted")
self.client.force_authenticate(user=member)
response = self.client.post(
"/api/time-entries/",
{
"workspace_id": str(workspace.id),
"project_id": str(project.id),
"description": "Blocked",
"start_time": make_aware(2026, 4, 24, 9, 0, 0).isoformat(),
"end_time": make_aware(2026, 4, 24, 10, 0, 0).isoformat(),
},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertTrue(
any("Selected project is unavailable." in item["message"] for item in response.data["messages"])
)
def test_member_can_create_time_entry_after_project_access_is_granted(self):
owner = User.objects.create_user(mobile="09120000011", password="secret123")
member = User.objects.create_user(mobile="09120000012", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=owner)
WorkspaceMembership.objects.create(
workspace=workspace,
user=member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
project = Project.objects.create(workspace=workspace, name="Accessible")
ProjectAccess.objects.create(project=project, user=member)
self.client.force_authenticate(user=member)
response = self.client.post(
"/api/time-entries/",
{
"workspace_id": str(workspace.id),
"project_id": str(project.id),
"description": "Allowed",
"start_time": make_aware(2026, 4, 24, 9, 0, 0).isoformat(),
"end_time": make_aware(2026, 4, 24, 10, 0, 0).isoformat(),
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["project"], str(project.id))

View File

@@ -3,6 +3,7 @@ from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import SetPasswordForm from django.contrib.auth.forms import SetPasswordForm
from django.db import transaction
from django.shortcuts import redirect from django.shortcuts import redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.urls import reverse_lazy from django.urls import reverse_lazy
@@ -11,6 +12,7 @@ from unfold.decorators import action as unfold_action
from core.admins.base import BaseAdmin, SoftDeleteListFilter from core.admins.base import BaseAdmin, SoftDeleteListFilter
from apps.users.models import UserSocialAccount
from apps.users.services.forms import CustomUserChangeForm, CustomUserCreationForm from apps.users.services.forms import CustomUserChangeForm, CustomUserCreationForm
User = get_user_model() User = get_user_model()
@@ -21,6 +23,34 @@ class UserResource(resources.ModelResource):
model = User model = User
class UserSocialAccountInline(admin.TabularInline):
model = UserSocialAccount
fk_name = "user"
extra = 0
autocomplete_fields = ("user",)
fields = (
"provider",
"provider_user_id",
"email",
"email_verified",
"avatar_url",
"is_deleted",
"created_at",
"updated_at",
)
readonly_fields = (
"provider",
"provider_user_id",
"email",
"email_verified",
"avatar_url",
"is_deleted",
"created_at",
"updated_at",
)
show_change_link = True
@admin.register(User) @admin.register(User)
class CustomUserAdmin(BaseUserAdmin, BaseAdmin): class CustomUserAdmin(BaseUserAdmin, BaseAdmin):
model = User model = User
@@ -136,6 +166,7 @@ class CustomUserAdmin(BaseUserAdmin, BaseAdmin):
), ),
) )
filter_horizontal = ("groups", "user_permissions") filter_horizontal = ("groups", "user_permissions")
inlines = (UserSocialAccountInline,)
actions_row = [ actions_row = [
"reset_password_action", "reset_password_action",
@@ -181,3 +212,53 @@ class CustomUserAdmin(BaseUserAdmin, BaseAdmin):
@admin.action(description="Deactivate selected users") @admin.action(description="Deactivate selected users")
def deactivate_users(self, request, queryset): def deactivate_users(self, request, queryset):
queryset.update(is_active=False) queryset.update(is_active=False)
@admin.register(UserSocialAccount)
class UserSocialAccountAdmin(BaseAdmin):
list_display = (
"provider",
"provider_user_id",
"user",
"email",
"email_verified",
"created_at",
"is_deleted",
)
search_fields = (
"provider_user_id",
"email",
"user__mobile",
"user__first_name",
"user__last_name",
)
list_filter = (
SoftDeleteListFilter,
"provider",
"email_verified",
"is_deleted",
"created_at",
)
readonly_fields = (
"id",
"created_at",
"updated_at",
"deleted_at",
)
autocomplete_fields = ("user",)
actions = (
"unlink_selected",
"hard_delete_selected",
"restore_selected",
)
@admin.action(description="Unlink selected social accounts")
def unlink_selected(self, request, queryset):
count = queryset.count()
with transaction.atomic():
queryset.hard_delete()
self.message_user(
request,
f"{count} social account link(s) permanently removed.",
level=messages.SUCCESS,
)

View File

@@ -1,22 +1,25 @@
import logging from django.contrib.auth import get_user_model, password_validation
import random from django.core.exceptions import ValidationError as DjangoValidationError
import string
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from django_redis import get_redis_connection
from drf_spectacular.utils import extend_schema_serializer from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers from rest_framework import serializers
from core.serializers.base import BaseModelSerializer from core.serializers.base import BaseModelSerializer
from apps.users.email_identity import normalize_email_identity
from apps.users.tasks import send_verification_sms
from apps.users.utils import record_login_attempt
User = get_user_model() User = get_user_model()
logger = logging.getLogger(__name__) INVALID_MOBILE_FORMAT_MESSAGE = "فرمت شماره موبایل نادرست است."
INVALID_MOBILE_NUMBER_MESSAGE = "شماره موبایل معتبر نیست."
PASSWORD_MISMATCH_MESSAGE = "رمز عبور مطابقت ندارد."
NEW_PASSWORD_MISMATCH_MESSAGE = "رمز عبور جدید و تکرار آن مطابقت ندارند."
PASSWORD_REUSE_MESSAGE = "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد."
def _raise_password_validation_error(password: str, *, user, field_name: str) -> None:
try:
password_validation.validate_password(password, user=user)
except DjangoValidationError as exc:
raise serializers.ValidationError({field_name: exc.messages[0] if len(exc.messages) == 1 else exc.messages})
class UserProfilePictureSerializer(BaseModelSerializer): class UserProfilePictureSerializer(BaseModelSerializer):
@@ -51,10 +54,10 @@ class RegisterSerializer(serializers.Serializer):
re_password = data.get("re_password", "") re_password = data.get("re_password", "")
if not (mobile.isdigit() and len(mobile) == 11): if not (mobile.isdigit() and len(mobile) == 11):
raise serializers.ValidationError({"mobile": "فرمت شماره موبایل نادرست است."}) raise serializers.ValidationError({"mobile": INVALID_MOBILE_FORMAT_MESSAGE})
if password != re_password: if password != re_password:
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."}) raise serializers.ValidationError({"password": PASSWORD_MISMATCH_MESSAGE})
return data return data
@@ -65,11 +68,8 @@ class SendOTPSerializer(serializers.Serializer):
mode = serializers.ChoiceField(choices=["register", "login", "forget_password"]) mode = serializers.ChoiceField(choices=["register", "login", "forget_password"])
def validate_mobile(self, value): def validate_mobile(self, value):
"""
Normalize and validate Iranian mobile numbers (example: 09XXXXXXXXX).
"""
if not value.isdigit() or len(value) != 11 or not value.startswith("09"): if not value.isdigit() or len(value) != 11 or not value.startswith("09"):
raise serializers.ValidationError("شماره موبایل معتبر نیست.") raise serializers.ValidationError(INVALID_MOBILE_NUMBER_MESSAGE)
return value return value
@@ -80,7 +80,7 @@ class LoginOtpSerializer(serializers.Serializer):
def validate_mobile(self, value): def validate_mobile(self, value):
if not (value.isdigit() and len(value) == 11): if not (value.isdigit() and len(value) == 11):
raise serializers.ValidationError("فرمت شماره موبایل نادرست است.") raise serializers.ValidationError(INVALID_MOBILE_FORMAT_MESSAGE)
return value return value
@@ -90,19 +90,57 @@ class LoginSerializer(serializers.Serializer):
def validate_mobile(self, value): def validate_mobile(self, value):
if not (value.isdigit() and len(value) == 11): if not (value.isdigit() and len(value) == 11):
raise serializers.ValidationError("فرمت شماره موبایل نادرست است.") raise serializers.ValidationError(INVALID_MOBILE_FORMAT_MESSAGE)
return value return value
class GoogleOAuthFlowSerializer(serializers.Serializer):
flow = serializers.CharField()
class GoogleOAuthCompleteSerializer(serializers.Serializer):
flow = serializers.CharField()
mobile = serializers.CharField(max_length=11)
def validate_mobile(self, value):
normalized = "".join(ch for ch in value if ch.isdigit())
if len(normalized) != 11 or not normalized.startswith("09"):
raise serializers.ValidationError(INVALID_MOBILE_FORMAT_MESSAGE)
return normalized
class GoogleOAuthClaimVerifySerializer(serializers.Serializer):
flow = serializers.CharField()
code = serializers.CharField(max_length=6)
class ResetPasswordSerializer(serializers.Serializer): class ResetPasswordSerializer(serializers.Serializer):
mobile = serializers.CharField(max_length=11) mobile = serializers.CharField(max_length=11)
code = serializers.CharField(max_length=6) code = serializers.CharField(max_length=6)
password = serializers.CharField(write_only=True) password = serializers.CharField(write_only=True)
re_password = serializers.CharField(write_only=True) re_password = serializers.CharField(write_only=True)
def validate_mobile(self, value):
if not value.isdigit() or len(value) != 11 or not value.startswith("09"):
raise serializers.ValidationError(INVALID_MOBILE_NUMBER_MESSAGE)
return value
def validate(self, data): def validate(self, data):
if data.get("password") != data.get("re_password"): mobile = data.get("mobile", "")
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."}) password = data.get("password", "")
re_password = data.get("re_password", "")
if password != re_password:
raise serializers.ValidationError({"password": PASSWORD_MISMATCH_MESSAGE})
user = User.objects.filter(mobile=mobile).only("password", "mobile", "first_name", "last_name", "email").first()
if user is not None:
_raise_password_validation_error(password, user=user, field_name="password")
if user.check_password(password):
raise serializers.ValidationError({"password": PASSWORD_REUSE_MESSAGE})
return data return data
@@ -112,8 +150,22 @@ class ChangePasswordSerializer(serializers.Serializer):
re_password = serializers.CharField(required=True, write_only=True) re_password = serializers.CharField(required=True, write_only=True)
def validate(self, data): def validate(self, data):
if data.get("new_password") != data.get("re_password"): old_password = data.get("old_password", "")
raise serializers.ValidationError({"new_password": "رمز عبور جدید و تکرار آن مطابقت ندارند."}) new_password = data.get("new_password", "")
re_password = data.get("re_password", "")
if new_password != re_password:
raise serializers.ValidationError({"new_password": NEW_PASSWORD_MISMATCH_MESSAGE})
request = self.context.get("request")
user = getattr(request, "user", None)
if old_password and old_password == new_password:
raise serializers.ValidationError({"new_password": PASSWORD_REUSE_MESSAGE})
if user is not None and getattr(user, "is_authenticated", False):
_raise_password_validation_error(new_password, user=user, field_name="new_password")
return data return data
@@ -135,23 +187,46 @@ class UserProfileSerializer(BaseModelSerializer):
full_name = serializers.ReadOnlyField() full_name = serializers.ReadOnlyField()
age = serializers.ReadOnlyField() age = serializers.ReadOnlyField()
def validate_email(self, value):
normalized = normalize_email_identity(value)
user = self.instance
if normalized is None:
return None
existing = User.objects.filter(email=normalized)
if user is not None:
existing = existing.exclude(pk=user.pk)
if existing.exists():
raise serializers.ValidationError("A user with this email already exists.")
return normalized
class Meta: class Meta:
model = User model = User
fields = BaseModelSerializer.Meta.fields + ( fields = BaseModelSerializer.Meta.fields + (
"mobile", "email", "first_name", "last_name", "mobile",
"description", "profile_picture", "birth_date", "email",
"is_verified", "full_name", "age" "first_name",
"last_name",
"description",
"profile_picture",
"birth_date",
"is_verified",
"is_demo",
"demo_expires_at",
"full_name",
"age",
) )
read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified") read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified", "is_demo", "demo_expires_at")
class UserSearchSerializer(serializers.ModelSerializer): class UserSearchSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = User model = User
fields = ( fields = (
'id', "id",
'first_name', "first_name",
'last_name', "last_name",
'mobile', "mobile",
'profile_picture', "profile_picture",
) )

140
apps/users/api/throttles.py Normal file
View File

@@ -0,0 +1,140 @@
from __future__ import annotations
import re
from django.core.exceptions import ImproperlyConfigured
from rest_framework.settings import api_settings
from rest_framework.throttling import SimpleRateThrottle
class ScopedMobileThrottle(SimpleRateThrottle):
scope = ""
def get_rate(self):
if not self.scope:
raise ImproperlyConfigured(
f"{self.__class__.__name__} must define a scope or rate."
)
try:
return api_settings.DEFAULT_THROTTLE_RATES[self.scope]
except KeyError as exc:
raise ImproperlyConfigured(
f'No default throttle rate set for scope "{self.scope}".'
) from exc
def parse_rate(self, rate):
if rate is None:
return (None, None)
num_requests, period = rate.split("/")
match = re.fullmatch(r"(?:(\d+)\s*)?([smhd]|sec|second|min|minute|hour|day)s?", period.strip(), re.IGNORECASE)
if not match:
return super().parse_rate(rate)
multiplier = int(match.group(1) or "1")
unit = match.group(2).lower()
unit_seconds = {
"s": 1,
"sec": 1,
"second": 1,
"m": 60,
"min": 60,
"minute": 60,
"h": 3600,
"hour": 3600,
"d": 86400,
"day": 86400,
}[unit]
return int(num_requests), multiplier * unit_seconds
def get_mobile_identifier(self, request) -> str | None:
mobile = None
try:
mobile = request.data.get("mobile")
except Exception:
mobile = None
if not isinstance(mobile, str):
return None
normalized = "".join(ch for ch in mobile if ch.isdigit())
return normalized or None
def get_cache_key(self, request, view):
ident = self.get_ident(request)
mobile = self.get_mobile_identifier(request)
if mobile:
return self.cache_format % {
"scope": self.scope,
"ident": f"{ident}:{mobile}",
}
return self.cache_format % {
"scope": self.scope,
"ident": ident,
}
def allow_request(self, request, view):
allowed = super().allow_request(request, view)
if not allowed:
request._throttle_scope = self.scope
request._retry_after_seconds = self.wait()
return allowed
class ScopedFlowMobileThrottle(ScopedMobileThrottle):
def get_mobile_identifier(self, request) -> str | None:
mobile = super().get_mobile_identifier(request)
if mobile:
return mobile
try:
flow = request.data.get("flow")
except Exception:
flow = None
if not isinstance(flow, str) or not flow:
return None
try:
from apps.users.services.google_oauth import get_google_flow
flow_payload = get_google_flow(flow)
except Exception:
return None
mobile = flow_payload.get("mobile")
if not isinstance(mobile, str):
return None
normalized = "".join(ch for ch in mobile if ch.isdigit())
return normalized or None
class OTPSendBurstThrottle(ScopedMobileThrottle):
scope = "otp_send_burst"
class OTPSendSustainedThrottle(ScopedMobileThrottle):
scope = "otp_send_sustained"
class PasswordLoginThrottle(ScopedMobileThrottle):
scope = "login_password"
class OTPLoginThrottle(ScopedMobileThrottle):
scope = "login_otp"
class GoogleClaimSendBurstThrottle(ScopedFlowMobileThrottle):
scope = "otp_send_burst"
class GoogleClaimSendSustainedThrottle(ScopedFlowMobileThrottle):
scope = "otp_send_sustained"
class GoogleClaimVerifyThrottle(ScopedFlowMobileThrottle):
scope = "login_otp"

View File

@@ -9,9 +9,16 @@ app_name = "users"
urlpatterns = [ urlpatterns = [
path("register/", views.RegisterWithOTPView.as_view(), name="register_verify"), path("register/", views.RegisterWithOTPView.as_view(), name="register_verify"),
path("register/password/", views.RegisterWithPasswordView.as_view(), name="register_password"),
path("otp/send/", views.SendOTPView.as_view(), name="send_otp"), path("otp/send/", views.SendOTPView.as_view(), name="send_otp"),
path("otp/login/", views.LoginOTPView.as_view(), name="login_otp"), path("otp/login/", views.LoginOTPView.as_view(), name="login_otp"),
path("login/", views.LoginView.as_view(), name="login"), path("login/", views.LoginView.as_view(), name="login"),
path("oauth/google/start/", views.GoogleOAuthStartView.as_view(), name="google_oauth_start"),
path("oauth/google/callback/", views.GoogleOAuthCallbackView.as_view(), name="google_oauth_callback"),
path("oauth/google/flow/", views.GoogleOAuthFlowView.as_view(), name="google_oauth_flow"),
path("oauth/google/complete/", views.GoogleOAuthCompleteView.as_view(), name="google_oauth_complete"),
path("oauth/google/claim/send-otp/", views.GoogleOAuthClaimSendOtpView.as_view(), name="google_oauth_claim_send_otp"),
path("oauth/google/claim/verify/", views.GoogleOAuthClaimVerifyView.as_view(), name="google_oauth_claim_verify"),
path("logout/", views.LogoutView.as_view(), name="logout"), path("logout/", views.LogoutView.as_view(), name="logout"),
path("password/set/", views.SetPasswordView.as_view(), name="set_password"), path("password/set/", views.SetPasswordView.as_view(), name="set_password"),
path("password/reset/", views.ResetPasswordView.as_view(), name="reset_password"), path("password/reset/", views.ResetPasswordView.as_view(), name="reset_password"),

View File

@@ -1,45 +1,72 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.http import HttpResponseRedirect
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, inline_serializer from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework import serializers, status from rest_framework import serializers, status
from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.generics import ListAPIView, UpdateAPIView from rest_framework.generics import ListAPIView, UpdateAPIView
from rest_framework.mixins import DestroyModelMixin, RetrieveModelMixin, UpdateModelMixin
from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework.mixins import UpdateModelMixin, RetrieveModelMixin, DestroyModelMixin
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from rest_framework_simplejwt.authentication import JWTAuthentication
from core.paginations.limit_offset import CustomLimitOffsetPagination
from apps.users.api.serializers import ( from apps.users.api.serializers import (
ChangePasswordSerializer, ChangePasswordSerializer,
GoogleOAuthClaimVerifySerializer,
GoogleOAuthCompleteSerializer,
GoogleOAuthFlowSerializer,
LoginOtpSerializer, LoginOtpSerializer,
LoginSerializer, LoginSerializer,
LogoutSerializer,
RegisterSerializer, RegisterSerializer,
RegisterWithPasswordSerializer,
ResetPasswordSerializer, ResetPasswordSerializer,
SendOTPSerializer, SendOTPSerializer,
TokenPairSerializer,
UserListSerializer, UserListSerializer,
UserProfilePictureSerializer, UserProfilePictureSerializer,
LogoutSerializer,
TokenPairSerializer,
RegisterWithPasswordSerializer,
UserProfileSerializer, UserProfileSerializer,
UserSearchSerializer, UserSearchSerializer,
) )
from apps.users.services.auth import ( from apps.users.api.throttles import (
register_user_with_password, GoogleClaimSendBurstThrottle,
register_user_with_otp, GoogleClaimSendSustainedThrottle,
generate_and_send_otp, GoogleClaimVerifyThrottle,
login_with_password, OTPLoginThrottle,
login_with_otp, OTPSendBurstThrottle,
reset_password_with_otp, OTPSendSustainedThrottle,
change_password, PasswordLoginThrottle,
logout_user
) )
from apps.users.services.auth import (
change_password,
generate_and_send_otp,
login_with_otp,
login_with_password,
logout_user,
register_user_with_otp,
register_user_with_password,
reset_password_with_otp,
)
from apps.users.services.google_oauth import (
build_authenticated_flow_payload,
build_google_authorization_url,
build_google_callback_error_redirect_url,
build_google_callback_redirect_url,
build_pending_google_flow_payload,
complete_google_signup,
consume_google_state,
create_google_flow,
exchange_code_for_google_profile,
find_social_account_for_profile,
get_google_flow,
send_google_claim_otp,
sync_user_from_google_profile,
verify_google_claim,
)
from core.paginations.limit_offset import CustomLimitOffsetPagination
User = get_user_model() User = get_user_model()
@@ -91,22 +118,24 @@ class SendOTPView(APIView):
+ password reset + password reset
""" """
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
throttle_classes = [OTPSendBurstThrottle, OTPSendSustainedThrottle]
@extend_schema(request=SendOTPSerializer, responses=None) @extend_schema(request=SendOTPSerializer, responses=None)
def post(self, request): def post(self, request):
serializer = SendOTPSerializer(data=request.data) serializer = SendOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
generate_and_send_otp( payload = generate_and_send_otp(
mobile=serializer.validated_data["mobile"], mobile=serializer.validated_data["mobile"],
mode=serializer.validated_data["mode"] mode=serializer.validated_data["mode"]
) )
return Response({"detail": "OTP sent successfully"}, status=status.HTTP_200_OK) return Response(payload, status=status.HTTP_200_OK)
class LoginView(APIView): class LoginView(APIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
throttle_classes = [PasswordLoginThrottle]
@extend_schema(request=LoginSerializer, responses=TokenPairSerializer) @extend_schema(request=LoginSerializer, responses=TokenPairSerializer)
def post(self, request): def post(self, request):
@@ -123,6 +152,7 @@ class LoginView(APIView):
class LoginOTPView(APIView): class LoginOTPView(APIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
throttle_classes = [OTPLoginThrottle]
@extend_schema(request=LoginOtpSerializer, responses=TokenPairSerializer) @extend_schema(request=LoginOtpSerializer, responses=TokenPairSerializer)
def post(self, request): def post(self, request):
@@ -137,6 +167,109 @@ class LoginOTPView(APIView):
return Response(tokens, status=status.HTTP_200_OK) return Response(tokens, status=status.HTTP_200_OK)
class GoogleOAuthStartView(APIView):
permission_classes = (AllowAny,)
@extend_schema(responses=None)
def get(self, request):
return HttpResponseRedirect(build_google_authorization_url())
class GoogleOAuthCallbackView(APIView):
permission_classes = (AllowAny,)
@extend_schema(responses=None)
def get(self, request):
if request.query_params.get("error"):
return HttpResponseRedirect(
build_google_callback_error_redirect_url(
code=request.query_params.get("error") or "google_sign_in_cancelled",
detail=(
request.query_params.get("error_description")
or "Google sign-in was cancelled."
),
)
)
try:
consume_google_state(request.query_params.get("state"))
profile = exchange_code_for_google_profile(request.query_params.get("code"))
social_account = find_social_account_for_profile(profile)
if social_account:
sync_user_from_google_profile(social_account.user, profile)
flow_payload = build_authenticated_flow_payload(social_account.user)
else:
flow_payload = build_pending_google_flow_payload(profile)
flow = create_google_flow(flow_payload)
return HttpResponseRedirect(build_google_callback_redirect_url(flow))
except serializers.ValidationError as exc:
detail = exc.detail
if isinstance(detail, dict):
message = detail.get("detail", "Google sign-in could not be completed.")
else:
message = detail
if isinstance(message, list):
message = message[0] if message else "Google sign-in could not be completed."
return HttpResponseRedirect(
build_google_callback_error_redirect_url(
code="google_callback_failed",
detail=str(message),
)
)
class GoogleOAuthFlowView(APIView):
permission_classes = (AllowAny,)
@extend_schema(responses=None)
def get(self, request):
serializer = GoogleOAuthFlowSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
return Response(get_google_flow(serializer.validated_data["flow"]), status=status.HTTP_200_OK)
class GoogleOAuthCompleteView(APIView):
permission_classes = (AllowAny,)
@extend_schema(request=GoogleOAuthCompleteSerializer, responses=None)
def post(self, request):
serializer = GoogleOAuthCompleteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = complete_google_signup(
flow=serializer.validated_data["flow"],
mobile=serializer.validated_data["mobile"],
)
return Response(payload, status=status.HTTP_200_OK)
class GoogleOAuthClaimSendOtpView(APIView):
permission_classes = (AllowAny,)
throttle_classes = [GoogleClaimSendBurstThrottle, GoogleClaimSendSustainedThrottle]
@extend_schema(request=GoogleOAuthFlowSerializer, responses=None)
def post(self, request):
serializer = GoogleOAuthFlowSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = send_google_claim_otp(serializer.validated_data["flow"])
return Response(payload, status=status.HTTP_200_OK)
class GoogleOAuthClaimVerifyView(APIView):
permission_classes = (AllowAny,)
throttle_classes = [GoogleClaimVerifyThrottle]
@extend_schema(request=GoogleOAuthClaimVerifySerializer, responses=None)
def post(self, request):
serializer = GoogleOAuthClaimVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = verify_google_claim(
flow=serializer.validated_data["flow"],
code=serializer.validated_data["code"],
)
return Response(payload, status=status.HTTP_200_OK)
class ResetPasswordView(APIView): class ResetPasswordView(APIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
serializer_class = ResetPasswordSerializer serializer_class = ResetPasswordSerializer
@@ -160,7 +293,12 @@ class ChangePasswordView(APIView):
@extend_schema(request=ChangePasswordSerializer) @extend_schema(request=ChangePasswordSerializer)
def patch(self, request, *args, **kwargs): def patch(self, request, *args, **kwargs):
serializer = ChangePasswordSerializer(data=request.data) if getattr(request.user, "is_demo", False):
return Response(
{"detail": "Demo accounts cannot change passwords."},
status=status.HTTP_403_FORBIDDEN,
)
serializer = ChangePasswordSerializer(data=request.data, context={"request": request})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
change_password( change_password(
@@ -194,6 +332,11 @@ class SetPasswordView(UpdateAPIView):
@extend_schema(request=ChangePasswordSerializer, responses=None) @extend_schema(request=ChangePasswordSerializer, responses=None)
def patch(self, request, *args, **kwargs): def patch(self, request, *args, **kwargs):
if getattr(request.user, "is_demo", False):
return Response(
{"detail": "Demo accounts cannot change passwords."},
status=status.HTTP_403_FORBIDDEN,
)
return super().patch(request, *args, **kwargs) return super().patch(request, *args, **kwargs)
def get_object(self): def get_object(self):
@@ -214,6 +357,11 @@ class ProfilePictureView(APIView):
operation_id="users_profile_picture_self_create", operation_id="users_profile_picture_self_create",
) )
def post(self, request): def post(self, request):
if getattr(request.user, "is_demo", False):
return Response(
{"detail": "Demo accounts cannot upload profile pictures."},
status=status.HTTP_403_FORBIDDEN,
)
serializer = UserProfilePictureSerializer( serializer = UserProfilePictureSerializer(
instance=request.user, instance=request.user,
data=request.data, data=request.data,
@@ -229,6 +377,11 @@ class ProfilePictureView(APIView):
operation_id="users_profile_picture_self_delete", operation_id="users_profile_picture_self_delete",
) )
def delete(self, request): def delete(self, request):
if getattr(request.user, "is_demo", False):
return Response(
{"detail": "Demo accounts cannot remove profile pictures."},
status=status.HTTP_403_FORBIDDEN,
)
request.user.profile_picture.delete(save=False) request.user.profile_picture.delete(save=False)
request.user.profile_picture = None request.user.profile_picture = None
request.user.save(update_fields=["profile_picture", "updated_at"]) request.user.save(update_fields=["profile_picture", "updated_at"])
@@ -268,6 +421,11 @@ class UserSearchAPIView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
if getattr(request.user, "is_demo", False):
return Response(
{"detail": "Demo accounts cannot search external users."},
status=status.HTTP_403_FORBIDDEN,
)
mobile = request.query_params.get('mobile') mobile = request.query_params.get('mobile')
if not mobile: if not mobile:
return Response( return Response(

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
def normalize_email_identity(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().lower()
return normalized or None
def mask_mobile(value: str | None) -> str | None:
if not value:
return None
if len(value) <= 4:
return value
return f"{value[:2]}{'*' * max(len(value) - 6, 1)}{value[-4:]}"

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
import json
from collections import defaultdict
from django.core.management.base import BaseCommand
from apps.users.email_identity import normalize_email_identity
from apps.users.models import User, UserSocialAccount
class Command(BaseCommand):
help = "Report suspicious Google social-account links without modifying data."
def handle(self, *args, **options):
issues: list[dict] = []
google_accounts = list(
UserSocialAccount.objects.select_related("user").filter(
provider=UserSocialAccount.ProviderType.GOOGLE
)
)
social_email_groups: dict[str, set[str]] = defaultdict(set)
user_by_email = {
user.email: user
for user in User.objects.exclude(email__isnull=True).only("id", "mobile", "email")
}
for account in google_accounts:
provider_email = normalize_email_identity(account.email)
user_email = normalize_email_identity(account.user.email)
if provider_email:
social_email_groups[provider_email].add(str(account.user_id))
if user_email and provider_email and user_email != provider_email:
issues.append(
{
"type": "linked_user_email_mismatch",
"linked_user_id": str(account.user_id),
"linked_user_mobile": account.user.mobile,
"linked_user_email": user_email,
"social_account_id": str(account.id),
"provider_email": provider_email,
"provider_user_id": account.provider_user_id,
}
)
other_user = user_by_email.get(provider_email) if provider_email else None
if other_user and other_user.id != account.user_id:
issues.append(
{
"type": "provider_email_matches_other_user",
"linked_user_id": str(account.user_id),
"linked_user_mobile": account.user.mobile,
"linked_user_email": user_email,
"social_account_id": str(account.id),
"provider_email": provider_email,
"provider_user_id": account.provider_user_id,
"other_user_id": str(other_user.id),
"other_user_mobile": other_user.mobile,
"other_user_email": other_user.email,
}
)
for provider_email, user_ids in social_email_groups.items():
if len(user_ids) <= 1:
continue
issues.append(
{
"type": "duplicate_provider_email_across_users",
"provider_email": provider_email,
"user_ids": sorted(user_ids),
}
)
if not issues:
self.stdout.write(self.style.SUCCESS("No suspicious Google social links found."))
return
for issue in issues:
self.stdout.write(json.dumps(issue, ensure_ascii=True, sort_keys=True))
self.stdout.write(self.style.WARNING(f"Reported {len(issues)} suspicious Google social link issue(s)."))

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.12 on 2026-04-30 20:35
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='UserSocialAccount',
fields=[
('id', models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('is_deleted', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=False)),
('provider', models.CharField(choices=[('google', 'google')], max_length=32)),
('provider_user_id', models.CharField(max_length=255)),
('email', models.EmailField(blank=True, default='', max_length=254)),
('email_verified', models.BooleanField(default=False)),
('avatar_url', models.URLField(blank=True, default='')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='social_accounts', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'user_social_account',
'verbose_name_plural': 'user_social_accounts',
'db_table': 'user_social_account',
'ordering': ('-updated_at', '-created_at'),
'indexes': [models.Index(fields=['provider', 'provider_user_id'], name='user_social_provider_uid_idx'), models.Index(fields=['provider', 'email'], name='user_social_provider_email_idx')],
'constraints': [models.UniqueConstraint(fields=('provider', 'provider_user_id'), name='user_social_account_provider_uid_uniq')],
},
),
]

View File

@@ -0,0 +1,63 @@
# Generated by Django 5.2.12 on 2026-05-14
from django.db import migrations, models
from django.db.models import Q
from django.db.models.functions import Lower
def _normalize_email(value):
if value is None:
return None
normalized = value.strip().lower()
return normalized or None
def normalize_user_and_social_emails(apps, schema_editor):
User = apps.get_model("users", "User")
UserSocialAccount = apps.get_model("users", "UserSocialAccount")
seen_emails = set()
for user in User.objects.all().order_by("created_at", "id"):
normalized_email = _normalize_email(user.email)
if normalized_email in seen_emails:
normalized_email = None
elif normalized_email is not None:
seen_emails.add(normalized_email)
if user.email != normalized_email:
user.email = normalized_email
user.save(update_fields=["email"])
for social_account in UserSocialAccount.objects.all().order_by("created_at", "id"):
normalized_email = _normalize_email(social_account.email)
if social_account.email != normalized_email:
social_account.email = normalized_email
social_account.save(update_fields=["email"])
class Migration(migrations.Migration):
dependencies = [
("users", "0002_usersocialaccount"),
]
operations = [
migrations.AlterField(
model_name="user",
name="email",
field=models.EmailField(blank=True, default=None, max_length=254, null=True),
),
migrations.AlterField(
model_name="usersocialaccount",
name="email",
field=models.EmailField(blank=True, default=None, max_length=254, null=True),
),
migrations.RunPython(normalize_user_and_social_emails, migrations.RunPython.noop),
migrations.AddConstraint(
model_name="user",
constraint=models.UniqueConstraint(
Lower("email"),
condition=Q(email__isnull=False),
name="user_email_ci_uniq",
),
),
]

Some files were not shown because too many files have changed in this diff Show More