Skip to content

Commit 3e6c8bd

Browse files
committed
Added Report.short_id. Fix #178
1 parent a6305fc commit 3e6c8bd

File tree

7 files changed

+102
-1
lines changed

7 files changed

+102
-1
lines changed

mosquito_alert/reports/admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class ReportChildAdmin(NestedPolymorphicModelAdmin, PolymorphicChildModelAdmin):
3131
show_in_index = True
3232
inlines = [ReadOnlyPhotoAdminInline, FlaggedContentNestedInlineAdmin]
3333
list_filter = ["observed_at", "created_at"]
34-
list_display = ["id", "user", "observed_at", "created_at", "updated_at"]
34+
list_display = ["id", "short_id", "user", "observed_at", "created_at", "updated_at"]
3535

3636
def get_form(self, request, obj=None, **kwargs):
3737
form = super().get_form(request, obj=obj, **kwargs)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.3 on 2024-10-18 09:46
2+
3+
from django.db import migrations
4+
import mosquito_alert.utils.fields
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("reports", "0006_alter_report_tags"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="report",
15+
name="short_id",
16+
field=mosquito_alert.utils.fields.ShortIDField(editable=False, size=10),
17+
),
18+
]

mosquito_alert/reports/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from mosquito_alert.moderation.models import FlagModeratedModel
1717
from mosquito_alert.tags.models import UUIDTaggedItem
1818
from mosquito_alert.taxa.models import Taxon
19+
from mosquito_alert.utils.fields import ShortIDField
1920
from mosquito_alert.utils.models import TimeStampedModel
2021

2122
from .managers import IndividualReportManager, ReportManager
@@ -45,6 +46,7 @@ class Report(GeoLocatedModel, FlagModeratedModel, TimeStampedModel, PolymorphicM
4546
# Attributes - Mandatory
4647
# NOTE: in case licensing is needed, get inspiration from django-licensing (it does not work)
4748
# license = models.ForeignKey(License, on_delete=models.PROTECT)
49+
short_id = ShortIDField(size=10, editable=False)
4850
# TODO: add location_is_modified or another location for the event_location.
4951
observed_at = models.DateTimeField(blank=True) # TODO: rename to event_datetime
5052
published = models.BooleanField(default=False) # TODO: make it published_at

mosquito_alert/reports/tests/test_models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ def test_photos_can_be_blank(self):
9494
def test_photos_can_be_sorted(self):
9595
assert self.model._meta.get_field("photos").sorted
9696

97+
def test_short_id_can_not_be_null(self):
98+
assert not self.model._meta.get_field("short_id").null
99+
100+
def test_short_id_size(self):
101+
assert self.model._meta.get_field("short_id").size == 10
102+
103+
def test_short_id_is_not_editable(self):
104+
assert not self.model._meta.get_field("short_id").editable
105+
97106
def test_observed_at_can_not_be_null(self):
98107
assert not self.model._meta.get_field("observed_at").null
99108

mosquito_alert/utils/fields.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
import math
2+
import secrets
3+
from typing import Any
4+
15
from django.apps import apps
6+
from django.db import models
27
from simple_history.models import HistoricalRecords, registered_models
38

49

@@ -57,3 +62,29 @@ def create_proxy_history_model(self, model, inherited, base_history):
5762
name = self.get_history_model_name(model)
5863
registered_models[opts.db_table] = model
5964
return type(str(name), (base_history,), attrs)
65+
66+
67+
class ShortIDField(models.CharField):
68+
"""
69+
A custom model field that generates a short, URL-safe identifier.
70+
"""
71+
72+
def __init__(self, size: int, *args, **kwargs) -> None:
73+
if not isinstance(size, int) or size <= 0:
74+
raise ValueError("Size must be a positive integer.")
75+
# See: https://zelark.github.io/nano-id-cc/
76+
self.size = size
77+
kwargs["max_length"] = size # Assign max_length from size
78+
kwargs["default"] = self.generate_short_id # Use the method to generate the default value
79+
super().__init__(*args, **kwargs)
80+
81+
def generate_short_id(self):
82+
"""Generates a URL-safe short ID of specified length."""
83+
return secrets.token_urlsafe(nbytes=math.ceil(self.size / 1.3))[: self.size]
84+
85+
def deconstruct(self) -> Any:
86+
name, path, args, kwargs = super().deconstruct()
87+
kwargs["size"] = self.size
88+
del kwargs["max_length"]
89+
del kwargs["default"]
90+
return name, path, args, kwargs

mosquito_alert/utils/tests/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from treebeard.mp_tree import MP_Node
44
from treebeard.ns_tree import NS_Node
55

6+
from ..fields import ShortIDField
67
from ..models import NodeExpandedQueriesMixin, ObservableMixin, ParentManageableNodeMixin, TimeStampedModel
78

89

@@ -44,3 +45,7 @@ class DummyObservableModel(ObservableMixin, models.Model):
4445

4546
class DummyCounterModel(models.Model):
4647
counter = models.IntegerField(default=0)
48+
49+
50+
class DummyShortIDModel(models.Model):
51+
short_id = ShortIDField(size=11)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import pytest
2+
3+
from ..fields import ShortIDField
4+
from .models import DummyShortIDModel
5+
6+
7+
@pytest.mark.django_db
8+
class TestShortIDField:
9+
def test_short_id_length(self):
10+
instance = DummyShortIDModel.objects.create()
11+
assert instance.short_id is not None
12+
assert len(instance.short_id) == 11 # Ensure length matches the specified size
13+
14+
def test_short_id_is_url_safe(self):
15+
instance = DummyShortIDModel.objects.create()
16+
assert all(c.isalnum() or c in "-_" for c in instance.short_id) # Check for URL-safe characters
17+
18+
def test_short_id_uniqueness(self):
19+
instances = [DummyShortIDModel.objects.create() for _ in range(100)] # Create multiple instances
20+
short_ids = [instance.short_id for instance in instances]
21+
assert len(short_ids) == len(set(short_ids)) # Ensure all short IDs are unique
22+
23+
def test_short_id_generation_on_create(self):
24+
instance1 = DummyShortIDModel.objects.create()
25+
instance2 = DummyShortIDModel.objects.create()
26+
assert instance1.short_id != instance2.short_id # Ensure different IDs are generated
27+
28+
def test_size_is_required(self):
29+
with pytest.raises(TypeError) as excinfo:
30+
ShortIDField() # Attempt to create a ShortIDField without the size parameter
31+
assert "missing 1 required positional argument: 'size'" in str(excinfo.value)
32+
33+
@pytest.mark.parametrize("size", [-1, 0, "str", None, 1.5])
34+
def test_size_raise_if_not_positive_integer(self, size):
35+
with pytest.raises(ValueError):
36+
ShortIDField(size=size)

0 commit comments

Comments
 (0)