Implement link groups

This commit is contained in:
Mark Veidemanis 2023-03-18 10:48:07 +00:00
parent 0723f14c53
commit bbd25c7450
Signed by: m
GPG Key ID: 5ACFCEED46C0904F
13 changed files with 440 additions and 71 deletions

View File

@ -25,6 +25,7 @@ from core.views import (
aggregators,
banks,
base,
linkgroups,
notifications,
platforms,
profit,
@ -230,4 +231,25 @@ urlpatterns = [
wallets.WalletDelete.as_view(),
name="wallet_delete",
),
# Link groups
path(
"links/<str:type>/",
linkgroups.LinkGroupList.as_view(),
name="linkgroups",
),
path(
"links/<str:type>/create/",
linkgroups.LinkGroupCreate.as_view(),
name="linkgroup_create",
),
path(
"links/<str:type>/update/<str:pk>/",
linkgroups.LinkGroupUpdate.as_view(),
name="linkgroup_update",
),
path(
"links/<str:type>/delete/<str:pk>/",
linkgroups.LinkGroupDelete.as_view(),
name="linkgroup_delete",
),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@ -8,6 +8,7 @@ from .models import (
Ad,
Aggregator,
Asset,
LinkGroup,
NotificationSettings,
Platform,
Provider,
@ -73,6 +74,7 @@ class AggregatorForm(RestrictedFormMixin, ModelForm):
"secret_id",
"secret_key",
"poll_interval",
"link_group",
"enabled",
)
help_texts = {
@ -81,6 +83,7 @@ class AggregatorForm(RestrictedFormMixin, ModelForm):
"secret_id": "The secret ID for the aggregator service.",
"secret_key": "The secret key for the aggregator service.",
"poll_interval": "The interval in seconds to poll the aggregator service.",
"link_group": "The link group to use for this aggregator connection.",
"enabled": "Whether or not the aggregator connection is enabled.",
}
@ -120,6 +123,7 @@ class PlatformForm(RestrictedFormMixin, ModelForm):
"base_usd",
"withdrawal_trigger",
"payees",
"link_group",
"enabled",
)
help_texts = {
@ -143,6 +147,7 @@ class PlatformForm(RestrictedFormMixin, ModelForm):
"base_usd": "The amount in USD to keep in the platform.",
"withdrawal_trigger": "The amount above the base USD to trigger a withdrawal.",
"payees": "The wallet addresses to send profit concerning this platform to.",
"link_group": "The link group to use for this platform.",
"enabled": "Whether or not the platform connection is enabled.",
}
@ -169,11 +174,12 @@ class AdForm(RestrictedFormMixin, ModelForm):
"dist_list",
"asset_list",
"provider_list",
"platforms",
"aggregators",
# "platforms",
# "aggregators",
"account_whitelist",
"send_reference",
"visible",
"link_group",
"enabled",
)
help_texts = {
@ -185,11 +191,12 @@ class AdForm(RestrictedFormMixin, ModelForm):
"dist_list": "Currency and country, space separated, one pair per line.",
"asset_list": "List of assets to distribute ads for.",
"provider_list": "List of providers to distribute ads for.",
"platforms": "Enabled platforms for this ad",
"aggregators": "Enabled aggregators for this ad",
# "platforms": "Enabled platforms for this ad",
# "aggregators": "Enabled aggregators for this ad",
"account_whitelist": "List of account IDs to use, one per line.",
"send_reference": "Whether or not to send the reference on new trades.",
"visible": "Whether or not this ad is visible.",
"link_group": "The link group to use for this ad.",
"enabled": "Whether or not this ad is enabled.",
}
@ -205,18 +212,18 @@ class AdForm(RestrictedFormMixin, ModelForm):
help_text=Meta.help_texts["provider_list"],
required=True,
)
platforms = forms.ModelMultipleChoiceField(
queryset=Platform.objects.all(),
widget=forms.CheckboxSelectMultiple,
help_text=Meta.help_texts["platforms"],
required=True,
)
aggregators = forms.ModelMultipleChoiceField(
queryset=Aggregator.objects.all(),
widget=forms.CheckboxSelectMultiple,
help_text=Meta.help_texts["aggregators"],
required=True,
)
# platforms = forms.ModelMultipleChoiceField(
# queryset=Platform.objects.all(),
# widget=forms.CheckboxSelectMultiple,
# help_text=Meta.help_texts["platforms"],
# required=True,
# )
# aggregators = forms.ModelMultipleChoiceField(
# queryset=Aggregator.objects.all(),
# widget=forms.CheckboxSelectMultiple,
# help_text=Meta.help_texts["aggregators"],
# required=True,
# )
class RequisitionForm(RestrictedFormMixin, ModelForm):
@ -254,3 +261,70 @@ class WalletForm(RestrictedFormMixin, ModelForm):
"name": "The name of the wallet.",
"address": "The XMR address to send funds to.",
}
class LinkGroupForm(RestrictedFormMixin, ModelForm):
class Meta:
model = LinkGroup
fields = (
"name",
"aggregators",
"platforms",
"platform_owner_cut_percentage",
"requisition_owner_cut_percentage",
"operator_cut_percentage",
"enabled",
)
help_texts = {
"name": "The name of the link group.",
"aggregators": "The aggregators to use.",
"platforms": "The platforms to use.",
"platform_owner_cut_percentage": "The percentage of the total profit of this group to give to the platform owners.",
"requisition_owner_cut_percentage": "The percentage of the total profit of this group to give to the requisition owners.",
"operator_cut_percentage": "The percentage of the total profit of this group to give to the operator.",
"enabled": "Whether or not this link group is enabled.",
}
platforms = forms.ModelMultipleChoiceField(
queryset=Platform.objects.all(),
widget=forms.CheckboxSelectMultiple,
help_text=Meta.help_texts["platforms"],
required=True,
)
aggregators = forms.ModelMultipleChoiceField(
queryset=Aggregator.objects.all(),
widget=forms.CheckboxSelectMultiple,
help_text=Meta.help_texts["aggregators"],
required=True,
)
def clean(self):
cleaned_data = super(LinkGroupForm, self).clean()
platform_owner_cut_percentage = cleaned_data.get(
"platform_owner_cut_percentage"
)
requisition_owner_cut_percentage = cleaned_data.get(
"requisition_owner_cut_percentage"
)
operator_cut_percentage = cleaned_data.get("operator_cut_percentage")
total_sum = (
platform_owner_cut_percentage
+ requisition_owner_cut_percentage
+ operator_cut_percentage
)
if total_sum != 100:
self.add_error(
"platform_owner_cut_percentage",
f"The sum of the percentages must be 100, not {total_sum}.",
)
self.add_error(
"requisition_owner_cut_percentage",
f"The sum of the percentages must be 100, not {total_sum}.",
)
self.add_error(
"operator_cut_percentage",
f"The sum of the percentages must be 100, not {total_sum}.",
)
return
return cleaned_data

View File

@ -0,0 +1,31 @@
# Generated by Django 4.1.7 on 2023-03-18 10:12
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0029_alter_requisition_id_alter_wallet_id'),
]
operations = [
migrations.CreateModel(
name='LinkGroup',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('platform_owner_cut_percentage', models.FloatField(default=0)),
('requisition_owner_cut_percentage', models.FloatField(default=0)),
('operator_cut_percentage', models.FloatField(default=0)),
('enabled', models.BooleanField(default=True)),
('aggregators', models.ManyToManyField(to='core.aggregator')),
('platforms', models.ManyToManyField(to='core.platform')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,45 @@
# Generated by Django 4.1.7 on 2023-03-18 10:38
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0030_linkgroup'),
]
operations = [
migrations.RemoveField(
model_name='ad',
name='aggregators',
),
migrations.RemoveField(
model_name='ad',
name='platforms',
),
migrations.RemoveField(
model_name='linkgroup',
name='aggregators',
),
migrations.RemoveField(
model_name='linkgroup',
name='platforms',
),
migrations.AddField(
model_name='ad',
name='link_group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.linkgroup'),
),
migrations.AddField(
model_name='aggregator',
name='link_group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.linkgroup'),
),
migrations.AddField(
model_name='platform',
name='link_group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.linkgroup'),
),
]

View File

@ -50,6 +50,26 @@ class NotificationSettings(models.Model):
return f"Notification settings for {self.user}"
class LinkGroup(models.Model):
"""
A group linking Aggregators, Platforms and defining a percentage split
that the owners of each should receive.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
platform_owner_cut_percentage = models.FloatField(default=0)
requisition_owner_cut_percentage = models.FloatField(default=0)
operator_cut_percentage = models.FloatField(default=0)
enabled = models.BooleanField(default=True)
def __str__(self):
return self.name
class Aggregator(models.Model):
"""
A connection to an API aggregator to pull transactions from bank accounts.
@ -70,6 +90,10 @@ class Aggregator(models.Model):
fetch_accounts = models.BooleanField(default=True)
link_group = models.ForeignKey(
LinkGroup, on_delete=models.CASCADE, null=True, blank=True
)
enabled = models.BooleanField(default=True)
def __str__(self):
@ -85,36 +109,41 @@ class Aggregator(models.Model):
@classmethod
def get_for_platform(cls, platform):
aggregators = []
ads = Ad.objects.filter(
platforms=platform,
enabled=True,
)
for ad in ads:
for aggregator in ad.aggregators.all():
if aggregator not in aggregators:
aggregators.append(aggregator)
# aggregators = []
# linkgroups = LinkGroup.objects.filter(
# platforms=platform,
# enabled=True,
# )
# for link in linkgroups:
# for aggregator in link.aggregators.all():
# if aggregator not in aggregators:
# aggregators.append(aggregator)
platform_link = platform.link_group
return aggregators
# return aggregators
return cls.objects.filter(
link_group=platform_link,
)
@property
def platforms(self):
"""
Get platforms for this aggregator.
Do this by looking up Ads with the aggregator.
Do this by looking up LinkGroups with the aggregator.
Then, join them all together.
"""
platforms = []
ads = Ad.objects.filter(
aggregators=self,
enabled=True,
)
for ad in ads:
for platform in ad.platforms.all():
if platform not in platforms:
platforms.append(platform)
return Platform.objects.filter(link_group=self.link_group)
# platforms = []
# linkgroups = LinkGroup.objects.filter(
# aggregators=self,
# enabled=True,
# )
# for link in linkgroups:
# for platform in link.platforms.all():
# if platform not in platforms:
# platforms.append(platform)
return platforms
# return platforms
@classmethod
def get_currencies_for_platform(cls, platform):
@ -168,6 +197,9 @@ class Wallet(models.Model):
name = models.CharField(max_length=255)
address = models.CharField(max_length=255)
def __str__(self):
return self.name
class Platform(models.Model):
"""
@ -205,6 +237,10 @@ class Platform(models.Model):
payees = models.ManyToManyField(Wallet, blank=True)
link_group = models.ForeignKey(
LinkGroup, on_delete=models.CASCADE, null=True, blank=True
)
enabled = models.BooleanField(default=True)
def __str__(self):
@ -214,7 +250,9 @@ class Platform(models.Model):
ad_id = self.platform_ad_ids.get(platform_ad_id, None)
if not ad_id:
return None
ad_object = Ad.objects.filter(id=ad_id, user=self.user, enabled=True).first()
ad_object = Ad.objects.filter(
id=ad_id, user=self.user, link_group=self.link_group, enabled=True
).first()
return ad_object
@classmethod
@ -234,7 +272,9 @@ class Platform(models.Model):
"""
Get all ads linked to this platform.
"""
return Ad.objects.filter(user=self.user, enabled=True, platforms=self)
return Ad.objects.filter(
user=self.user, enabled=True, link_group=self.link_group
)
@property
def ads_assets(self):
@ -343,36 +383,45 @@ class Platform(models.Model):
@classmethod
def get_for_aggregator(cls, aggregator):
platforms = []
ads = Ad.objects.filter(
aggregators=aggregator,
enabled=True,
)
for ad in ads:
for platform in ad.platforms.all():
if platform not in platforms:
platforms.append(platform)
# platforms = []
# linkgroups = LinkGroup.objects.filter(
# aggregators=aggregator,
# enabled=True,
# )
# for link in linkgroups:
# for platform in link.platforms.all():
# if platform not in platforms:
# platforms.append(platform)
return platforms
# return platforms
aggregator_link = aggregator.link_group
return cls.objects.filter(
link_group=aggregator_link,
)
@property
def aggregators(self):
"""
Get aggregators for this platform.
Do this by looking up Ads with the platform.
Do this by looking up LinkGroups with the platform.
Then, join them all together.
"""
aggregators = []
ads = Ad.objects.filter(
platforms=self,
enabled=True,
)
for ad in ads:
for aggregator in ad.aggregators.all():
if aggregator not in aggregators:
aggregators.append(aggregator)
# aggregators = []
# linkgroups = LinkGroup.objects.filter(
# platforms=self,
# enabled=True,
# )
# for link in linkgroups:
# for aggregator in link.aggregators.all():
# if aggregator not in aggregators:
# aggregators.append(aggregator)
return aggregators
# return aggregators
return Aggregator.objects.filter(
link_group=self.link_group,
)
def get_requisition(self, aggregator_id, requisition_id):
"""
@ -425,15 +474,15 @@ class Ad(models.Model):
asset_list = models.ManyToManyField(Asset)
provider_list = models.ManyToManyField(Provider)
platforms = models.ManyToManyField(Platform)
aggregators = models.ManyToManyField(Aggregator)
account_map = models.JSONField(default=dict)
account_whitelist = models.TextField(null=True, blank=True)
send_reference = models.BooleanField(default=True)
visible = models.BooleanField(default=True)
link_group = models.ForeignKey(
LinkGroup, on_delete=models.CASCADE, null=True, blank=True
)
enabled = models.BooleanField(default=True)
@property

View File

@ -272,7 +272,10 @@
Platform Connections
</a>
<a class="navbar-item" href="{% url 'wallets' type='page' %}">
Wallets
Profit Wallets
</a>
<a class="navbar-item" href="{% url 'linkgroups' type='page' %}">
Link Groups
</a>
</div>
</div>

View File

@ -28,6 +28,19 @@
<td>{{ item.accounts|length }}</td>
<td>
<div class="buttons">
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'requisition_update' type=type aggregator_id=pk req_id=item.id %}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
class="button">
<span class="icon-text">
<span class="icon has-text-black" data-tooltip="Configure">
<i class="fa-solid fa-wrench"></i>
</span>
</span>
</button>
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{% url 'req_delete' type=type pk=pk req_id=item.id %}"

View File

@ -0,0 +1,85 @@
{% load cache %}
{% load cachalot cache %}
{% get_last_invalidation 'core.LinkGroup' as last %}
{% include 'mixins/partials/notify.html' %}
{# cache 600 objects_linkgroups request.user.id object_list type last #}
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"
id="{{ context_object_name }}-table"
hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}">
<thead>
<th>id</th>
<th>user</th>
<th>name</th>
<th>platform</th>
<th>requisition</th>
<th>operator</th>
<th>enabled</th>
<th>actions</th>
</thead>
{% for item in object_list %}
<tr>
<td>
<a
class="has-text-grey"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</td>
<td>{{ item.user }}</td>
<td>{{ item.name }}</td>
<td>{{ item.platform_owner_cut_percentage }}</td>
<td>{{ item.requisition_owner_cut_percentage }}</td>
<td>{{ item.operator_cut_percentage }}</td>
<td>
{% if item.enabled %}
<span class="icon">
<i class="fa-solid fa-check"></i>
</span>
{% else %}
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
{% endif %}
</td>
<td>
<div class="buttons">
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'linkgroup_update' type=type pk=item.id %}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-pencil"></i>
</span>
</span>
</button>
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{% url 'linkgroup_delete' type=type pk=item.id %}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
hx-confirm="Are you sure you wish to delete {{ item.name }}?"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
</span>
</button>
</div>
</td>
</tr>
{% endfor %}
</table>
{# endcache #}

View File

@ -1,6 +1,6 @@
{% load cache %}
{% load cachalot cache %}
{% get_last_invalidation 'core.Platform' as last %}
{% get_last_invalidation 'core.Trade' as last %}
{% include 'mixins/partials/notify.html' %}
{# cache 600 objects_platform_trades request.user.id object_list type last #}
{% for platform_name, trade_map in object_list.items %}

View File

@ -1,8 +1,8 @@
{% load cache %}
{% load cachalot cache %}
{% get_last_invalidation 'core.Ad' as last %}
{% get_last_invalidation 'core.Wallet' as last %}
{% include 'mixins/partials/notify.html' %}
{# cache 600 objects_ads request.user.id object_list type last #}
{# cache 600 objects_wallets request.user.id object_list type last #}
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"

View File

@ -1,7 +1,7 @@
from django.test import TransactionTestCase
from core.clients.platform import LocalPlatformClient
from core.models import Ad, Asset, Provider, Requisition
from core.models import Ad, Asset, LinkGroup, Provider, Requisition
from core.tests.helpers import AggregatorPlatformMixin
@ -93,11 +93,22 @@ class TestPlatform(AggregatorPlatformMixin, TransactionTestCase):
self.ad.asset_list.set([asset])
self.ad.provider_list.set([provider])
self.ad.platforms.set([self.platform])
self.ad.aggregators.set([self.aggregator])
# self.ad.platforms.set([self.platform])
# self.ad.aggregators.set([self.aggregator])
self.ad.save()
self.linkgroup = LinkGroup.objects.create(
user=self.user,
name="Test",
)
self.aggregator.link_group = self.linkgroup
self.aggregator.save()
self.platform.link_group = self.linkgroup
self.platform.save()
self.req = Requisition.objects.create(
user=self.user,
aggregator=self.aggregator,

36
core/views/linkgroups.py Normal file
View File

@ -0,0 +1,36 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from two_factor.views.mixins import OTPRequiredMixin
from core.forms import LinkGroupForm
from core.models import LinkGroup
class LinkGroupList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
list_template = "partials/linkgroup-list.html"
model = LinkGroup
page_title = "List of link groups"
page_subtitle = "Link groups are used to link aggregators and platforms"
list_url_name = "linkgroups"
list_url_args = ["type"]
submit_url_name = "linkgroup_create"
class LinkGroupCreate(LoginRequiredMixin, OTPRequiredMixin, ObjectCreate):
model = LinkGroup
form_class = LinkGroupForm
submit_url_name = "linkgroup_create"
class LinkGroupUpdate(LoginRequiredMixin, OTPRequiredMixin, ObjectUpdate):
model = LinkGroup
form_class = LinkGroupForm
submit_url_name = "linkgroup_update"
class LinkGroupDelete(LoginRequiredMixin, OTPRequiredMixin, ObjectDelete):
model = LinkGroup

View File

@ -9,7 +9,7 @@ from core.models import Wallet
class WalletList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
list_template = "partials/wallet-list.html"
model = Wallet
page_title = "List of wallets"
page_title = "List of wallets to send profit to"
list_url_name = "wallets"
list_url_args = ["type"]