feat(rates): record hourly rate history
This commit is contained in:
@@ -1,6 +1,35 @@
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.projects.models import ProjectUserRate
|
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):
|
def get_current_project_user_rate(*, project, user):
|
||||||
@@ -27,6 +56,7 @@ def upsert_project_user_rate(*, project, user, hourly_rate, currency="USD"):
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
effective_from = timezone.now()
|
||||||
if rate:
|
if rate:
|
||||||
update_fields = []
|
update_fields = []
|
||||||
if rate.is_deleted:
|
if rate.is_deleted:
|
||||||
@@ -43,16 +73,31 @@ def upsert_project_user_rate(*, project, user, hourly_rate, currency="USD"):
|
|||||||
if update_fields:
|
if update_fields:
|
||||||
update_fields.append("updated_at")
|
update_fields.append("updated_at")
|
||||||
rate.save(update_fields=update_fields)
|
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
|
return rate
|
||||||
|
|
||||||
return ProjectUserRate.objects.create(
|
rate = ProjectUserRate.objects.create(
|
||||||
project=project,
|
project=project,
|
||||||
user=user,
|
user=user,
|
||||||
hourly_rate=hourly_rate,
|
hourly_rate=hourly_rate,
|
||||||
currency=currency,
|
currency=currency,
|
||||||
effective_from=timezone.now(),
|
effective_from=effective_from,
|
||||||
is_active=True,
|
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):
|
def remove_project_user_rate(*, project, user):
|
||||||
|
|||||||
78
apps/workspaces/migrations/0008_hourlyratehistory.py
Normal file
78
apps/workspaces/migrations/0008_hourlyratehistory.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-05-26 08:20
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def seed_rate_history(apps, schema_editor):
|
||||||
|
HourlyRateHistory = apps.get_model('workspaces', 'HourlyRateHistory')
|
||||||
|
WorkspaceUserRate = apps.get_model('workspaces', 'WorkspaceUserRate')
|
||||||
|
ProjectUserRate = apps.get_model('projects', 'ProjectUserRate')
|
||||||
|
|
||||||
|
workspace_rows = [
|
||||||
|
HourlyRateHistory(
|
||||||
|
workspace_id=rate.workspace_id,
|
||||||
|
user_id=rate.user_id,
|
||||||
|
project_id=None,
|
||||||
|
scope='workspace',
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=rate.effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
for rate in WorkspaceUserRate.objects.filter(is_deleted=False)
|
||||||
|
]
|
||||||
|
project_rows = [
|
||||||
|
HourlyRateHistory(
|
||||||
|
workspace_id=rate.project.workspace_id,
|
||||||
|
user_id=rate.user_id,
|
||||||
|
project_id=rate.project_id,
|
||||||
|
scope='project',
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=rate.effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
for rate in ProjectUserRate.objects.select_related('project').filter(is_deleted=False)
|
||||||
|
]
|
||||||
|
HourlyRateHistory.objects.bulk_create(workspace_rows + project_rows, ignore_conflicts=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('projects', '0005_project_thumbnail'),
|
||||||
|
('workspaces', '0007_workspacemembership_membership_ws_active_user_idx'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='HourlyRateHistory',
|
||||||
|
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)),
|
||||||
|
('scope', models.CharField(choices=[('workspace', 'Workspace'), ('project', 'Project')], max_length=16)),
|
||||||
|
('hourly_rate', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('currency', models.CharField(default='USD', max_length=3)),
|
||||||
|
('effective_from', models.DateTimeField()),
|
||||||
|
('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(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='hourly_rate_history', 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='hourly_rate_history', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hourly_rate_history', to='workspaces.workspace')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'hourly_rate_history',
|
||||||
|
'ordering': ('effective_from', 'created_at'),
|
||||||
|
'indexes': [models.Index(fields=['workspace', 'user', 'effective_from'], name='hrh_ws_user_eff_idx'), models.Index(fields=['project', 'user', 'effective_from'], name='hrh_project_user_eff_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RunPython(seed_rate_history, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -173,3 +173,53 @@ class WorkspaceUserRate(BaseModel):
|
|||||||
"currency": self.currency,
|
"currency": self.currency,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HourlyRateHistory(BaseModel):
|
||||||
|
class Scope(models.TextChoices):
|
||||||
|
WORKSPACE = "workspace", "Workspace"
|
||||||
|
PROJECT = "project", "Project"
|
||||||
|
|
||||||
|
workspace = models.ForeignKey(
|
||||||
|
Workspace,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="hourly_rate_history",
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="hourly_rate_history",
|
||||||
|
)
|
||||||
|
project = models.ForeignKey(
|
||||||
|
"projects.Project",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="hourly_rate_history",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
scope = models.CharField(max_length=16, choices=Scope.choices)
|
||||||
|
hourly_rate = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
currency = models.CharField(max_length=3, default="USD")
|
||||||
|
effective_from = models.DateTimeField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "hourly_rate_history"
|
||||||
|
ordering = ("effective_from", "created_at")
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["workspace", "user", "effective_from"], name="hrh_ws_user_eff_idx"),
|
||||||
|
models.Index(fields=["project", "user", "effective_from"], name="hrh_project_user_eff_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_additional_data(self):
|
||||||
|
return build_workspace_log_metadata(
|
||||||
|
section=SECTION_RATES,
|
||||||
|
workspace_id=self.workspace_id,
|
||||||
|
target_id=self.id,
|
||||||
|
target_label=self.user.full_name or self.user.mobile,
|
||||||
|
extra={
|
||||||
|
"rate_user_id": str(self.user_id),
|
||||||
|
"project_id": str(self.project_id) if self.project_id else None,
|
||||||
|
"scope": self.scope,
|
||||||
|
"currency": self.currency,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.workspaces.models import WorkspaceUserRate
|
from apps.workspaces.models import HourlyRateHistory, WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
|
def record_workspace_rate_history(*, workspace, user_id, hourly_rate, currency, effective_from=None):
|
||||||
|
currency = currency.upper()
|
||||||
|
effective_from = effective_from or timezone.now()
|
||||||
|
latest = (
|
||||||
|
HourlyRateHistory.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
user_id=user_id,
|
||||||
|
scope=HourlyRateHistory.Scope.WORKSPACE,
|
||||||
|
project__isnull=True,
|
||||||
|
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=workspace,
|
||||||
|
user_id=user_id,
|
||||||
|
project=None,
|
||||||
|
scope=HourlyRateHistory.Scope.WORKSPACE,
|
||||||
|
hourly_rate=hourly_rate,
|
||||||
|
currency=currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"):
|
def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"):
|
||||||
@@ -11,6 +39,7 @@ def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"):
|
|||||||
is_deleted=False,
|
is_deleted=False,
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
effective_from = timezone.now()
|
||||||
if rate:
|
if rate:
|
||||||
update_fields = []
|
update_fields = []
|
||||||
if rate.hourly_rate != hourly_rate:
|
if rate.hourly_rate != hourly_rate:
|
||||||
@@ -25,16 +54,31 @@ def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"):
|
|||||||
if update_fields:
|
if update_fields:
|
||||||
update_fields.append("updated_at")
|
update_fields.append("updated_at")
|
||||||
rate.save(update_fields=update_fields)
|
rate.save(update_fields=update_fields)
|
||||||
|
record_workspace_rate_history(
|
||||||
|
workspace=workspace,
|
||||||
|
user_id=user_id,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
)
|
||||||
return rate
|
return rate
|
||||||
|
|
||||||
return WorkspaceUserRate.objects.create(
|
rate = WorkspaceUserRate.objects.create(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
hourly_rate=hourly_rate,
|
hourly_rate=hourly_rate,
|
||||||
currency=currency,
|
currency=currency,
|
||||||
effective_from=timezone.now(),
|
effective_from=effective_from,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
record_workspace_rate_history(
|
||||||
|
workspace=workspace,
|
||||||
|
user_id=user_id,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=rate.effective_from,
|
||||||
|
)
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
def update_workspace_user_rate(rate_instance, **kwargs):
|
def update_workspace_user_rate(rate_instance, **kwargs):
|
||||||
@@ -50,5 +94,12 @@ def update_workspace_user_rate(rate_instance, **kwargs):
|
|||||||
if update_fields:
|
if update_fields:
|
||||||
update_fields.append("updated_at")
|
update_fields.append("updated_at")
|
||||||
rate_instance.save(update_fields=update_fields)
|
rate_instance.save(update_fields=update_fields)
|
||||||
|
if {"hourly_rate", "currency", "is_active"} & set(update_fields):
|
||||||
|
record_workspace_rate_history(
|
||||||
|
workspace=rate_instance.workspace,
|
||||||
|
user_id=rate_instance.user_id,
|
||||||
|
hourly_rate=rate_instance.hourly_rate,
|
||||||
|
currency=rate_instance.currency,
|
||||||
|
)
|
||||||
|
|
||||||
return rate_instance
|
return rate_instance
|
||||||
|
|||||||
Reference in New Issue
Block a user