Skip to content

Commit

Permalink
feat(api): reworking the admin and model for tos. Have introduced a '… (
Browse files Browse the repository at this point in the history
#558)

* feat(api): reworking the admin and model for tos. Have introduced a 'final' flag for tos. Once a tos is final it cannot be changed any more (from the admin)

* feat(api): added action to make tos active

---------

Co-authored-by: Gerald Iakobinyi-Pich <gerald@gitcoin.co>
  • Loading branch information
nutrina and Gerald Iakobinyi-Pich authored Mar 28, 2024
1 parent 88c58f5 commit f3cbfb8
Show file tree
Hide file tree
Showing 8 changed files with 219 additions and 21 deletions.
3 changes: 1 addition & 2 deletions api/account/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@
class HexStringField(models.CharField):
def __init__(self, *args, **kwargs):
if "validators" not in kwargs:
kwargs["validators"] = []
kwargs["validators"] = [HEXA_VALID]

kwargs["validators"] += [HEXA_VALID]
super(HexStringField, self).__init__(*args, **kwargs)

def get_prep_value(self, value):
Expand Down
44 changes: 41 additions & 3 deletions api/tos/admin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
# Register your models here.
from typing import Any
from django import forms
from django.contrib import admin
from django.http import HttpRequest
from django_ace import AceWidget
from scorer.scorer_admin import ScorerModelAdmin
from django.contrib.auth import get_permission_codename
from django.contrib import messages

from .models import Tos, TosAcceptanceProof

Expand Down Expand Up @@ -36,10 +40,44 @@ class Meta:
@admin.register(Tos)
class TosAdmin(ScorerModelAdmin):
form = TosForm
list_display = ("id", "type", "active", "created_at", "modified_at")
readonly_fields = ("created_at", "modified_at")
list_display = ("id", "type", "active", "final", "created_at", "modified_at")
readonly_fields = ["created_at", "modified_at"]
search_fields = ("content", "type")
list_filter = ["active", "type"]
list_filter = ["active", "final", "type"]

actions = ["make_active"]

def get_readonly_fields(
self, request: HttpRequest, obj: Tos | None = ...
) -> list[str] | tuple[Any, ...]:
ret = ["created_at", "modified_at"]
# An active object shall not be made inactive ...
if obj and obj.final:
ret += ["final", "content", "type"]
return ret

@admin.action(description="Make selected TOS active", permissions=["activate"])
def make_active(self, request, queryset):
count = len(queryset)
if count > 1:
self.message_user.e
messages.error(request, "You can only activate 1 tos")
return

prev_list = list(Tos.objects.filter(active=True))
if prev_list:
prev_active = prev_list[0]
prev_active.active = False
prev_active.save()
queryset.update(active=True)

def has_activate_permission(self, request):
"""Does the user have the activate permission?"""

opts = self.opts
codename = get_permission_codename("activate", opts)
ret = request.user.has_perm("%s.%s" % (opts.app_label, codename))
return ret


@admin.register(TosAcceptanceProof)
Expand Down
1 change: 0 additions & 1 deletion api/tos/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ def check_tos_accepted(tos_type: str, address: str) -> TosAccepted:

def get_tos_to_sign(tos_type: str, address: str) -> TosToSign:
text, nonce = Tos.get_message_with_nonce(tos_type)

return TosToSign(text=text, nonce=nonce)


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Generated by Django 4.2.6 on 2024-03-26 16:52

import account.models
import django.core.validators
from django.db import migrations, models
import re


class Migration(migrations.Migration):
dependencies = [
("tos", "0001_initial"),
]

operations = [
migrations.RemoveConstraint(
model_name="tos",
name="unique_active_tops_for_type",
),
migrations.AddField(
model_name="tos",
name="final",
field=models.BooleanField(
default=False,
help_text="Once an object is made final, it cannot be edited any more. This is to prevent from editing TOS that might have been accepted by users.",
),
),
migrations.AlterField(
model_name="tos",
name="active",
field=models.BooleanField(
default=False,
help_text="Only 1 active tos is alloed per type. The active is the one that will be required for users to approve",
),
),
migrations.AlterField(
model_name="tosacceptanceproof",
name="signature",
field=account.models.EthSignature(
max_length=132,
validators=[
django.core.validators.RegexValidator(
re.compile("^0x[A-Fa-f0-9]+$"),
"Enter a valid hex string ",
"invalid",
)
],
),
),
migrations.AddConstraint(
model_name="tos",
constraint=models.UniqueConstraint(
condition=models.Q(("active", True), ("final", True)),
fields=("type", "final", "active"),
name="Only 1 active final tos is allowed for any given type",
),
),
]
16 changes: 16 additions & 0 deletions api/tos/migrations/0003_alter_tos_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Generated by Django 4.2.6 on 2024-03-28 19:50

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("tos", "0002_remove_tos_unique_active_tops_for_type_tos_final_and_more"),
]

operations = [
migrations.AlterModelOptions(
name="tos",
options={"permissions": (("activate_tos", "Can activate TOS instances"),)},
),
]
21 changes: 15 additions & 6 deletions api/tos/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@ class TosType(models.TextChoices):
type = models.CharField(
max_length=3, choices=TosType.choices, blank=False, db_index=True
)
active = models.BooleanField(default=False)
active = models.BooleanField(
default=False,
help_text="Only 1 active tos is alloed per type. The active is the one that will be required for users to approve",
)
final = models.BooleanField(
default=False,
help_text="Once an object is made final, it cannot be edited any more. This is to prevent from editing TOS that might have been accepted by users.",
)
content = models.TextField(blank=False, null=False)

def __str__(self):
Expand All @@ -28,14 +35,14 @@ def get_message_for_nonce(self, nonce: str) -> str:

@classmethod
def get_message_with_nonce(cls, type: str) -> tuple[str, str]:
tos = Tos.objects.get(type=type, active=True)
tos = Tos.objects.get(type=type, active=True, final=True)
nonce = Nonce.create_nonce()
return tos.get_message_for_nonce(nonce.nonce), nonce.nonce

@classmethod
def accept(cls, type: str, nonce: str, signature: str) -> bool:
if Nonce.use_nonce(nonce):
tos = Tos.objects.get(type=type, active=True)
tos = Tos.objects.get(type=type, active=True, final=True)
encoded_message = encode_defunct(text=tos.get_message_for_nonce(nonce))
address = Account.recover_message(
encoded_message,
Expand All @@ -55,12 +62,14 @@ class Meta:
constraints = [
# Ensure only 1 active object at 1 time
UniqueConstraint(
fields=["type", "active"],
name="unique_active_tops_for_type",
condition=Q(active=True),
fields=["type", "final", "active"],
name="Only 1 active final tos is allowed for any given type",
condition=Q(final=True, active=True),
),
]

permissions = (("activate_tos", "Can activate TOS instances"),)


class TosAcceptanceProof(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
Expand Down
18 changes: 11 additions & 7 deletions api/tos/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,9 @@


class TestTos:
# def setUp(self):
# # create the TOS object
# Tos.objects.create(
# type=Tos.TosType.IDENTITY_STAKING, active=True, content="Hello World !!!"
# )
"""
This will test the API functions that are exposed in the ceramic-cache app.
"""

def test_check_tos_accepted_when_no_tos_exists(self, sample_token):
"""Test that accepted is not confirmed when tos does not exist."""
Expand Down Expand Up @@ -84,7 +82,10 @@ def test_get_check_to_sign(self, sample_token):

# Create multiple tos, but we only check for the active one in the end
Tos.objects.create(
type=Tos.TosType.IDENTITY_STAKING, active=True, content="Hello World !!!"
type=Tos.TosType.IDENTITY_STAKING,
active=True,
final=True,
content="Hello World !!!",
)

Tos.objects.create(
Expand All @@ -111,7 +112,10 @@ def test_accept_tos(self, sample_token):

# Create multiple tos, but we only check for the active one in the end
tos = Tos.objects.create(
type=Tos.TosType.IDENTITY_STAKING, active=True, content="Hello World !!!"
type=Tos.TosType.IDENTITY_STAKING,
active=True,
final=True,
content="Hello World !!!",
)

# get a message to sign
Expand Down
80 changes: 78 additions & 2 deletions api/tos/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from eth_account.messages import encode_defunct
from tos.models import Tos, TosAcceptanceProof
from web3 import Web3
from django.db.utils import IntegrityError

web3 = Web3()
web3.eth.account.enable_unaudited_hdwallet_features()
Expand All @@ -15,7 +16,10 @@ class TestTosModelFunctions:
def test_accept_tos(self):
# create the TOS object
tos = Tos.objects.create(
type=Tos.TosType.IDENTITY_STAKING, active=True, content="Hello World !!!"
type=Tos.TosType.IDENTITY_STAKING,
active=True,
final=True,
content="Hello World !!!",
)

# get a message to sign
Expand Down Expand Up @@ -46,10 +50,82 @@ def test_accept_tos(self):

def test_has_any_accepted(self):
tos = Tos.objects.create(
type=Tos.TosType.IDENTITY_STAKING, active=True, content="Hello World !!!"
type=Tos.TosType.IDENTITY_STAKING,
active=True,
final=True,
content="Hello World !!!",
)
assert tos.has_any_accepted() is False
TosAcceptanceProof.objects.create(
tos=tos, address="0x0", signature="0x0", nonce="12345"
)
assert tos.has_any_accepted() is True

def test_get_message_with_nonce(self):
tos = Tos.objects.create(
type=Tos.TosType.IDENTITY_STAKING, content="Hello World !!!"
)

# get_message_with_nonce shall only return if a tos
# with active=true and final=true exists
with pytest.raises(Tos.DoesNotExist):
tos.get_message_with_nonce(Tos.TosType.IDENTITY_STAKING)

tos = Tos.objects.create(
type=Tos.TosType.IDENTITY_STAKING, active=True, content="Hello World !!!"
)

with pytest.raises(Tos.DoesNotExist):
tos.get_message_with_nonce(Tos.TosType.IDENTITY_STAKING)

tos = Tos.objects.create(
type=Tos.TosType.IDENTITY_STAKING, final=True, content="Hello World !!!"
)

with pytest.raises(Tos.DoesNotExist):
tos.get_message_with_nonce(Tos.TosType.IDENTITY_STAKING)

tos = Tos.objects.create(
type=Tos.TosType.IDENTITY_STAKING,
active=True,
final=True,
content="Hello World !!!",
)

r = tos.get_message_with_nonce(Tos.TosType.IDENTITY_STAKING)

assert len(r) == 2

def test_constraint(self):
# Make sure only 1 active and final tos can exist
Tos.objects.create(
type=Tos.TosType.IDENTITY_STAKING,
active=True,
final=True,
content="Hello World !!!",
)

# This works
Tos.objects.create(
type=Tos.TosType.IDENTITY_STAKING,
active=False,
final=True,
content="Hello World !!!",
)

# This works as well
Tos.objects.create(
type=Tos.TosType.IDENTITY_STAKING,
active=True,
final=False,
content="Hello World !!!",
)

# This violates the constraint
with pytest.raises(IntegrityError):
Tos.objects.create(
type=Tos.TosType.IDENTITY_STAKING,
active=True,
final=True,
content="Hello World !!!",
)

0 comments on commit f3cbfb8

Please sign in to comment.