diff --git a/apps/projects/services/rates.py b/apps/projects/services/rates.py index ec72838..cca4913 100644 --- a/apps/projects/services/rates.py +++ b/apps/projects/services/rates.py @@ -1,6 +1,35 @@ 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): @@ -27,6 +56,7 @@ def upsert_project_user_rate(*, project, user, hourly_rate, currency="USD"): .first() ) + effective_from = timezone.now() if rate: update_fields = [] if rate.is_deleted: @@ -43,16 +73,31 @@ def upsert_project_user_rate(*, project, user, hourly_rate, currency="USD"): 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 - return ProjectUserRate.objects.create( + rate = ProjectUserRate.objects.create( project=project, user=user, hourly_rate=hourly_rate, currency=currency, - effective_from=timezone.now(), + 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): diff --git a/apps/workspaces/migrations/0008_hourlyratehistory.py b/apps/workspaces/migrations/0008_hourlyratehistory.py new file mode 100644 index 0000000..7921ce2 --- /dev/null +++ b/apps/workspaces/migrations/0008_hourlyratehistory.py @@ -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), + ] diff --git a/apps/workspaces/models.py b/apps/workspaces/models.py index aa5583d..1a50d1d 100644 --- a/apps/workspaces/models.py +++ b/apps/workspaces/models.py @@ -173,3 +173,53 @@ class WorkspaceUserRate(BaseModel): "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, + }, + ) diff --git a/apps/workspaces/services/rates.py b/apps/workspaces/services/rates.py index 0fc2cc6..aa67002 100644 --- a/apps/workspaces/services/rates.py +++ b/apps/workspaces/services/rates.py @@ -1,6 +1,34 @@ 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"): @@ -11,6 +39,7 @@ def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"): is_deleted=False, ).first() + effective_from = timezone.now() if rate: update_fields = [] 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: update_fields.append("updated_at") 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 WorkspaceUserRate.objects.create( + rate = WorkspaceUserRate.objects.create( workspace=workspace, user_id=user_id, hourly_rate=hourly_rate, currency=currency, - effective_from=timezone.now(), + effective_from=effective_from, 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): @@ -50,5 +94,12 @@ def update_workspace_user_rate(rate_instance, **kwargs): if update_fields: update_fields.append("updated_at") 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