Skip to content

Commit

Permalink
feat: add Adjustment model and admin
Browse files Browse the repository at this point in the history
  • Loading branch information
iloveagent57 committed Oct 11, 2023
1 parent 0f2e264 commit d550eb2
Show file tree
Hide file tree
Showing 4 changed files with 255 additions and 1 deletion.
74 changes: 73 additions & 1 deletion openedx_ledger/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from django_object_actions import DjangoObjectActions
from simple_history.admin import SimpleHistoryAdmin

from openedx_ledger import constants, models, views
from openedx_ledger import api, constants, models, views


def can_modify():
Expand Down Expand Up @@ -36,6 +36,12 @@ class Meta:
model = models.Ledger

fields = ('uuid', 'idempotency_key', 'unit', 'balance_usd', 'metadata')
# The autocomplete_fields of AdjustmentAdmin include the ledger field,
# which in turn requires that we define here that ledgers are
# searchable by uuid.
search_fields = [
'uuid',
]
if can_modify():
readonly_fields = ('uuid', 'balance_usd')
else:
Expand Down Expand Up @@ -176,3 +182,69 @@ class Meta:

model = models.Reversal
fields = '__all__'


@admin.register(models.Adjustment)
class AdjustmentAdmin(SimpleHistoryAdmin):
"""
Admin configuration for the Adjustment model.
"""

class Meta:
"""
Metaclass for ReversalAdmin.
"""

model = models.Adjustment

fields = [
'ledger',
'quantity',
'transaction_of_interest',
'reason',
'notes',
'uuid',
'idempotency_key',
'transaction',
'created',
'modified',
]

list_display = (
'uuid',
'get_ledger_uuid',
'quantity',
'reason',
'created',
'modified',
)
list_filter = (
'reason',
)
autocomplete_fields = [
'ledger',
'transaction_of_interest',
]
readonly_fields = [
'uuid',
'idempotency_key',
'transaction',
'created',
'modified',
]

@admin.display(ordering='uuid', description='Ledger uuid')
def get_ledger_uuid(self, obj):
return obj.ledger.uuid

def save_model(self, request, obj, form, change):
if change:
super().save_model(request, obj, form, change)
else:
api.create_adjustment(
ledger=obj.ledger,
quantity=obj.quantity,
reason=obj.reason,
notes=obj.notes,
transaction_of_interest=obj.transaction_of_interest,
)
40 changes: 40 additions & 0 deletions openedx_ledger/api.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"""
The openedx_ledger python API.
"""
import logging
import uuid

from django.db.transaction import atomic, get_connection

from openedx_ledger import models, utils
from openedx_ledger.signals.signals import TRANSACTION_REVERSED


logger = logging.getLogger(__name__)


class LedgerBalanceExceeded(Exception):
"""
Exception for when a transaction could not be created because it would exceed the ledger balance.
Expand Down Expand Up @@ -147,3 +153,37 @@ def create_ledger(unit=None, idempotency_key=None, subsidy_uuid=None, initial_de
)

return ledger


def create_adjustment(ledger, quantity, reason, notes, transaction_of_interest=None, **metadata):
"""
Creates a new Transaction and related Adjustment record
to adjust the balance of the given ledger.
"""
breakpoint()
idempotency_key = f'{ledger.uuid}-adjustment-{quantity}-reason-{uuid.uuid4()}'
transaction = create_transaction(
ledger,
quantity,
idempotency_key=idempotency_key,
**metadata,
)
try:
adjustment = models.Adjustment.objects.create(
ledger=ledger,
quantity=quantity,
idempotency_key=idempotency_key,
transaction=transaction,
transaction_of_interest=transaction_of_interest,
reason=reason,
notes=notes,
)
transaction.state = models.TransactionStateChoices.COMMITTED
except Exception as exc: # pylint: disable=broad-except
logger.exception('Failed to create adjustment')
transaction.state = models.TransactionStateChoices.FAILED
raise exc
finally:
transaction.save()

return adjustment
69 changes: 69 additions & 0 deletions openedx_ledger/migrations/0008_add_adjustment_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Generated by Django 3.2.19 on 2023-10-11 20:02

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import jsonfield.fields
import model_utils.fields
import simple_history.models
import uuid


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('openedx_ledger', '0007_alter_externalfulfillmentprovider_name'),
]

operations = [
migrations.CreateModel(
name='HistoricalAdjustment',
fields=[
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)),
('idempotency_key', models.CharField(db_index=True, help_text="An idempotency key is a unique value generated by the client of the Ledger API which the Ledger and Transaction APIs use to recognize subsequent retries of the same redemption (i.e. a Transaction that leads to the fulfillment of an enrollment or entitlement for a particular user id in a particular content key).We suggest incorporating V4 UUIDs along with sufficiently unique or random values representing the desired redemption to avoid unintended collisions.In particular, a Transaction's idempotency_key should incorporate it's corresponding ledger's idempotency_key.Utility methods are provided in the ``utils.py`` module to help clients generate appropriate idempotency keys.", max_length=255)),
('quantity', models.BigIntegerField(help_text='How many units (as defined in the associated Ledger instance) this Transaction represents.')),
('metadata', jsonfield.fields.JSONField(blank=True, help_text='Any additionaly metadata that a client may want to associate with this Transaction.', null=True)),
('state', models.CharField(choices=[('created', 'Created'), ('pending', 'Pending'), ('committed', 'Committed'), ('failed', 'Failed')], db_index=True, default='created', help_text="The current state of the Transaction. One of: ['Created', 'Pending', 'Committed', 'Failed']", max_length=255)),
('reason', models.CharField(choices=[('unauthorized_enrollment', 'Unauthorized enrollment'), ('poor_content_fit', 'Poor content fit'), ('technical_challenges', 'Technical challenges'), ('missed_refund_or_date', 'Missed refund or date'), ('good_faith', 'Good faith/Relationship building')], db_index=True, default='technical_challenges', help_text='The primary reason for the existence of this adjustment.', max_length=255)),
('notes', models.TextField(blank=True, help_text='Any additional context you have for the existence of this adjustment.', null=True)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('ledger', models.ForeignKey(blank=True, db_constraint=False, help_text='The Ledger instance with which this Adjustment is associated.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='openedx_ledger.ledger')),
('transaction', models.ForeignKey(blank=True, db_constraint=False, help_text='The Transaction instance which adjusts the balance of the relevant ledger.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='openedx_ledger.transaction')),
('transaction_of_interest', models.ForeignKey(blank=True, db_constraint=False, help_text='Any transaction of interest w.r.t. the reason for being of this adjustment. For example, the transaction of interest may point to some transaction record for which the enrolling user is unsatisfied, but for which we cannot issue a reversal due to business rules.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='openedx_ledger.transaction')),
],
options={
'verbose_name': 'historical adjustment',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': 'history_date',
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='Adjustment',
fields=[
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('idempotency_key', models.CharField(db_index=True, help_text="An idempotency key is a unique value generated by the client of the Ledger API which the Ledger and Transaction APIs use to recognize subsequent retries of the same redemption (i.e. a Transaction that leads to the fulfillment of an enrollment or entitlement for a particular user id in a particular content key).We suggest incorporating V4 UUIDs along with sufficiently unique or random values representing the desired redemption to avoid unintended collisions.In particular, a Transaction's idempotency_key should incorporate it's corresponding ledger's idempotency_key.Utility methods are provided in the ``utils.py`` module to help clients generate appropriate idempotency keys.", max_length=255)),
('quantity', models.BigIntegerField(help_text='How many units (as defined in the associated Ledger instance) this Transaction represents.')),
('metadata', jsonfield.fields.JSONField(blank=True, help_text='Any additionaly metadata that a client may want to associate with this Transaction.', null=True)),
('state', models.CharField(choices=[('created', 'Created'), ('pending', 'Pending'), ('committed', 'Committed'), ('failed', 'Failed')], db_index=True, default='created', help_text="The current state of the Transaction. One of: ['Created', 'Pending', 'Committed', 'Failed']", max_length=255)),
('reason', models.CharField(choices=[('unauthorized_enrollment', 'Unauthorized enrollment'), ('poor_content_fit', 'Poor content fit'), ('technical_challenges', 'Technical challenges'), ('missed_refund_or_date', 'Missed refund or date'), ('good_faith', 'Good faith/Relationship building')], db_index=True, default='technical_challenges', help_text='The primary reason for the existence of this adjustment.', max_length=255)),
('notes', models.TextField(blank=True, help_text='Any additional context you have for the existence of this adjustment.', null=True)),
('ledger', models.ForeignKey(help_text='The Ledger instance with which this Adjustment is associated.', on_delete=django.db.models.deletion.CASCADE, related_name='adjustments', to='openedx_ledger.ledger')),
('transaction', models.OneToOneField(help_text='The Transaction instance which adjusts the balance of the relevant ledger.', on_delete=django.db.models.deletion.CASCADE, related_name='adjustment', to='openedx_ledger.transaction')),
('transaction_of_interest', models.OneToOneField(help_text='Any transaction of interest w.r.t. the reason for being of this adjustment. For example, the transaction of interest may point to some transaction record for which the enrolling user is unsatisfied, but for which we cannot issue a reversal due to business rules.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='adjustment_of_interest', to='openedx_ledger.transaction')),
],
options={
'abstract': False,
},
),
]
73 changes: 73 additions & 0 deletions openedx_ledger/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ class TransactionStateChoices:
)


class AdjustmentReasonChoices:
UNAUTHROZIED_ENROLLMENT = 'unauthorized_enrollment'
POOR_CONTENT_FIT = 'poor_content_fit'
TECHNICAL_CHALLENGES = 'technical_challenges'
MISSED_REFUND_OR_DATE = 'missed_refund_or_date'
GOOD_FAITH = 'good_faith'
CHOICES = (
(UNAUTHROZIED_ENROLLMENT, 'Unauthorized enrollment'),
(POOR_CONTENT_FIT, 'Poor content fit'),
(TECHNICAL_CHALLENGES, 'Technical challenges'),
(MISSED_REFUND_OR_DATE, 'Missed refund or date'),
(GOOD_FAITH, 'Good faith/Relationship building'),
)


class LedgerLockAttemptFailed(Exception):
"""
Raise when attempt to lock Ledger failed due to an already existing lock.
Expand Down Expand Up @@ -491,3 +506,61 @@ class Meta:
history = HistoricalRecords()
# Reversal quantities should always have the opposite sign of the transaction (i.e. negative)
# We have to enforce this somehow...


class Adjustment(BaseTransaction):
"""
Represents some adjustment to the balance of a ledger via
a transaction (with quantity > 0) and some audit fields.
"""
ledger = models.ForeignKey(
Ledger,
related_name='adjustments',
null=False,
on_delete=models.CASCADE,
help_text=(
"The Ledger instance with which this Adjustment is associated."
)
)
transaction = models.OneToOneField(
Transaction,
related_name='adjustment',
unique=True,
null=False,
on_delete=models.CASCADE,
help_text=(
"The Transaction instance which adjusts the balance of the relevant ledger."
),
)
transaction_of_interest = models.OneToOneField(
Transaction,
related_name='adjustment_of_interest',
null=True,
blank=True,
on_delete=models.SET_NULL,
help_text=(
"Any transaction of interest w.r.t. the reason for being of this adjustment. "
"For example, the transaction of interest may point to some transaction record "
"for which the enrolling user is unsatisfied, but for which we cannot issue a reversal "
"due to business rules."
),
)
reason = models.CharField(
max_length=255,
blank=False,
null=False,
choices=AdjustmentReasonChoices.CHOICES,
default=AdjustmentReasonChoices.TECHNICAL_CHALLENGES,
db_index=True,
help_text=(
'The primary reason for the existence of this adjustment.'
),
)
notes = models.TextField(
blank=True,
null=True,
help_text=(
'Any additional context you have for the existence of this adjustment.'
),
)
history = HistoricalRecords()

0 comments on commit d550eb2

Please sign in to comment.