Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 5.2.9 on 2026-01-29 22:17

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('environments_app', '0004_environment_collection'),
]

operations = [
migrations.AlterModelOptions(
name='environmentvariable',
options={'ordering': ['order', 'id']},
),
migrations.AddField(
model_name='environmentvariable',
name='order',
field=models.IntegerField(default=0),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Data migration to set initial order for existing environment variables."""
from django.db import migrations


def set_initial_order(apps, schema_editor):
EnvironmentVariable = apps.get_model('environments_app', 'EnvironmentVariable')
# Group by environment and order alphabetically by key
env_ids = EnvironmentVariable.objects.values_list(
'environment_id', flat=True
).distinct()
for env_id in env_ids:
variables = EnvironmentVariable.objects.filter(
environment_id=env_id
).order_by('key')
for index, var in enumerate(variables):
var.order = index + 1
var.save(update_fields=['order'])


class Migration(migrations.Migration):

dependencies = [
('environments_app', '0005_add_order_to_environmentvariable'),
]

operations = [
migrations.RunPython(set_initial_order, migrations.RunPython.noop),
]
11 changes: 10 additions & 1 deletion backend/environments_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,23 @@ class EnvironmentVariable(BaseModel):
enabled = models.BooleanField(default=True)
# Link group: variables with same link_group sync their selected_value_index
link_group = models.CharField(max_length=255, blank=True, null=True)
order = models.IntegerField(default=0)

class Meta:
ordering = ['key']
ordering = ['order', 'id']
unique_together = ['environment', 'key']

def __str__(self):
return f"{self.environment.name}.{self.key}"

def save(self, *args, **kwargs):
if self._state.adding and self.order == 0:
max_order = EnvironmentVariable.objects.filter(
environment=self.environment
).order_by('-order').values_list('order', flat=True).first()
self.order = (max_order or 0) + 1
super().save(*args, **kwargs)

@property
def current_value(self):
"""Get the currently selected value."""
Expand Down
2 changes: 1 addition & 1 deletion backend/environments_app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class Meta:
fields = [
'id', 'environment', 'key', 'values', 'selected_value_index',
'current_value', 'description', 'is_secret', 'enabled', 'link_group',
'created_at', 'updated_at'
'order', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'environment', 'created_at', 'updated_at', 'current_value']

Expand Down
17 changes: 17 additions & 0 deletions backend/environments_app/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,20 @@ def remove_value(self, request, environment_pk=None, pk=None):

variable.remove_value(index)
return Response(EnvironmentVariableSerializer(variable).data)

@action(detail=False, methods=['post'], url_path='reorder')
def reorder(self, request, environment_pk=None):
"""Reorder variables by updating their order field."""
variable_ids = request.data.get('variable_ids', [])
if not variable_ids:
return Response(
{'error': 'variable_ids is required'},
status=status.HTTP_400_BAD_REQUEST
)

for index, var_id in enumerate(variable_ids):
EnvironmentVariable.objects.filter(
id=var_id, environment_id=environment_pk
).update(order=index)

return Response({'status': 'ok'})
Empty file.
Empty file.
Empty file.
19 changes: 19 additions & 0 deletions backend/licensing_app/management/commands/generate_license_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.core.management.base import BaseCommand

from licensing_app.services import generate_license_key


class Command(BaseCommand):
help = 'Generate a valid PostAI license key'

def add_arguments(self, parser):
parser.add_argument(
'-n', '--count',
type=int,
default=1,
help='Number of keys to generate (default: 1)',
)

def handle(self, *args, **options):
for _ in range(options['count']):
self.stdout.write(generate_license_key())
29 changes: 29 additions & 0 deletions backend/licensing_app/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 5.2.9 on 2026-01-28 22:57

import uuid
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
]

operations = [
migrations.CreateModel(
name='License',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('trial_started_at', models.DateTimeField(auto_now_add=True)),
('license_key', models.CharField(blank=True, default='', max_length=100)),
('activated_at', models.DateTimeField(blank=True, null=True)),
],
options={
'ordering': ['created_at'],
},
),
]
18 changes: 18 additions & 0 deletions backend/licensing_app/migrations/0002_license_row_signature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.9 on 2026-01-29 16:43

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('licensing_app', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='license',
name='row_signature',
field=models.CharField(blank=True, default='', max_length=64),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.9 on 2026-01-29 16:44

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('licensing_app', '0002_license_row_signature'),
]

operations = [
migrations.AlterField(
model_name='license',
name='trial_started_at',
field=models.DateTimeField(),
),
]
Empty file.
32 changes: 32 additions & 0 deletions backend/licensing_app/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from django.db import models
from core.models import BaseModel


class License(BaseModel):
trial_started_at = models.DateTimeField()
license_key = models.CharField(max_length=100, blank=True, default='')
activated_at = models.DateTimeField(null=True, blank=True)
row_signature = models.CharField(max_length=64, blank=True, default='')

class Meta:
ordering = ['created_at']

def __str__(self):
if self.license_key:
return f'License (activated)'
return f'License (trial)'

def save(self, **kwargs):
from django.utils import timezone
from .services import sign_license
if not self.trial_started_at:
self.trial_started_at = timezone.now()
sign_license(self)
super().save(**kwargs)

@classmethod
def get_instance(cls):
instance = cls.objects.first()
if not instance:
instance = cls.objects.create()
return instance
13 changes: 13 additions & 0 deletions backend/licensing_app/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from rest_framework import serializers


class LicenseStatusSerializer(serializers.Serializer):
trial_started_at = serializers.DateTimeField()
days_remaining = serializers.IntegerField()
is_trial = serializers.BooleanField()
is_activated = serializers.BooleanField()
is_expired = serializers.BooleanField()


class LicenseActivateSerializer(serializers.Serializer):
license_key = serializers.CharField(max_length=100)
58 changes: 58 additions & 0 deletions backend/licensing_app/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import hashlib
import hmac
import re

HMAC_SECRET = b'postai-license-key-secret-2024'
ROW_SIGN_SECRET = b'postai-row-integrity-8f3k2m9x'
KEY_PATTERN = re.compile(r'^POSTAI-([0-9A-Fa-f]{5})-([0-9A-Fa-f]{5})-([0-9A-Fa-f]{5})-([0-9A-Fa-f]{5})$')


def validate_license_key(key: str) -> bool:
match = KEY_PATTERN.match(key.strip())
if not match:
return False

groups = match.groups()
hex_chars = ''.join(groups).lower()
payload = hex_chars[:16]
check = hex_chars[16:20]

digest = hmac.new(HMAC_SECRET, payload.encode(), hashlib.sha256).hexdigest()
return digest[:4].lower() == check.lower()


def generate_license_key() -> str:
"""Generate a valid license key (for testing/admin use)."""
import secrets
payload = secrets.token_hex(8) # 16 hex chars
digest = hmac.new(HMAC_SECRET, payload.encode(), hashlib.sha256).hexdigest()
check = digest[:4]
raw = payload + check
return f'POSTAI-{raw[0:5]}-{raw[5:10]}-{raw[10:15]}-{raw[15:20]}'.upper()


def compute_row_signature(trial_started_at: str, license_key: str, activated_at: str) -> str:
"""Compute HMAC signature over the license row's critical fields."""
message = f'{trial_started_at}|{license_key}|{activated_at}'
return hmac.new(ROW_SIGN_SECRET, message.encode(), hashlib.sha256).hexdigest()


def sign_license(instance) -> None:
"""Compute and store the row signature on a License instance."""
instance.row_signature = compute_row_signature(
str(instance.trial_started_at.isoformat()) if instance.trial_started_at else '',
instance.license_key or '',
str(instance.activated_at.isoformat()) if instance.activated_at else '',
)


def verify_license(instance) -> bool:
"""Verify that a License row has not been tampered with."""
if not instance.row_signature:
return False
expected = compute_row_signature(
str(instance.trial_started_at.isoformat()) if instance.trial_started_at else '',
instance.license_key or '',
str(instance.activated_at.isoformat()) if instance.activated_at else '',
)
return hmac.compare_digest(instance.row_signature, expected)
Loading