Skip to content

Commit e7acbb2

Browse files
authored
Merge pull request #172 from bas-amop/tz/tagging
Add tag field route model, serialiser and route request view
2 parents 3845210 + 6d1aeb6 commit e7acbb2

File tree

11 files changed

+295
-15
lines changed

11 files changed

+295
-15
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- added ensure_adminuser command to add subtly more sophisticated behaviour to Django's createsuperuser - i.e. don't raise non-zero exit code if superuser already exists, add more useful output.
1313
- Use uv in the docker image.
1414
- Empty arrays to empty responses for a consistent response structure.
15+
- Adding a "tags" field to the Route model. As an optional parameter, tags can be assigned to routes using a POST api/route request. This is implemented using [django-taggit](https://django-taggit.readthedocs.io/en/latest).
1516

1617
### Changed
1718
- Inappropriate use of 204 code: RecentRoutesView changed from 204 to 200 OK with an empty array and the original message ("No recent routes found for today.").

docs/apischema.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,14 @@ components:
763763
default: false
764764
description: If true, forces recalculation even if an existing route is
765765
found.
766+
tags:
767+
type: array
768+
items:
769+
type: string
770+
maxLength: 50
771+
nullable: true
772+
description: Optional tags for route (e.g., ['archive', 'SD056']). Can also
773+
accept a single string or comma-separated string.
766774
required:
767775
- end_lat
768776
- end_lon

polarrouteserver/route_api/admin.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class RouteAdmin(admin.ModelAdmin):
1616
"id",
1717
"display_start",
1818
"display_end",
19+
"display_tags",
1920
"requested",
2021
"calculated",
2122
"job_id",
@@ -24,28 +25,35 @@ class RouteAdmin(admin.ModelAdmin):
2425
"polar_route_version",
2526
]
2627
ordering = ("-requested",)
28+
list_filter = ("tags", "calculated", "requested")
29+
search_fields = ("start_name", "end_name", "tags__name")
2730

2831
list_select_related = ("mesh",)
2932

3033
def get_queryset(self, request):
3134
# Load only the fields necessary for the changelist view
3235
queryset = super().get_queryset(request)
33-
return queryset.defer("json", "json_unsmoothed", "mesh__json")
36+
return queryset.defer("json", "json_unsmoothed", "mesh__json").prefetch_related(
37+
"tags"
38+
)
3439

3540
def get_fieldsets(self, request, obj=None):
36-
# contain the json fields in a collapsed section of the page
41+
# Contain the json fields in a collapsed section of the page
3742
collapsed_fields = ("json", "json_unsmoothed")
3843
if obj:
44+
# Get regular model fields excluding collapsed ones and id
45+
regular_fields = [
46+
f.name
47+
for f in self.model._meta.fields
48+
if f.name not in collapsed_fields + ("id",)
49+
]
50+
# Add tags field (it is a TaggableManager, not a regular field)
51+
regular_fields.append("tags")
52+
3953
return [
4054
(
4155
None,
42-
{
43-
"fields": [
44-
f.name
45-
for f in self.model._meta.fields
46-
if f.name not in collapsed_fields + ("id",)
47-
]
48-
},
56+
{"fields": regular_fields},
4957
),
5058
(
5159
"Click to expand JSON fields",
@@ -67,16 +75,24 @@ def display_end(self, obj):
6775
else:
6876
return f"({obj.end_lat},{obj.end_lon})"
6977

78+
def display_tags(self, obj):
79+
"""Display tags as a comma-separated string."""
80+
tags = obj.tags.all()
81+
if tags:
82+
return ", ".join([tag.name for tag in tags])
83+
return "-"
84+
7085
def job_id(self, obj):
7186
job = obj.job_set.latest("datetime")
7287
return f"{job.id}"
7388

7489
display_start.short_description = "Start (lat,lon)"
7590
display_end.short_description = "End (lat,lon)"
91+
display_tags.short_description = "Tags"
7692
job_id.short_description = "Job ID (latest)"
7793

7894
def get_readonly_fields(self, request, obj=None):
79-
editable_fields = ("requested", "calculated", "start_name", "end_name")
95+
editable_fields = ("requested", "calculated", "start_name", "end_name", "tags")
8096

8197
if obj:
8298
# Return a list of all field names on the model
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 5.2.8 on 2025-12-09 16:23
2+
3+
import taggit.managers
4+
from django.db import migrations
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("route_api", "0016_alter_location_name"),
10+
(
11+
"taggit",
12+
"0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx",
13+
),
14+
]
15+
16+
operations = [
17+
migrations.AddField(
18+
model_name="route",
19+
name="tags",
20+
field=taggit.managers.TaggableManager(
21+
blank=True,
22+
help_text="Tags for route categorization",
23+
through="taggit.TaggedItem",
24+
to="taggit.Tag",
25+
verbose_name="Tags",
26+
),
27+
),
28+
]

polarrouteserver/route_api/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from celery.result import AsyncResult
44
from django.db import models
55
from django.utils import timezone
6+
from taggit.managers import TaggableManager
67

78
from polarrouteserver.celery import app
89

@@ -71,6 +72,7 @@ class Route(models.Model):
7172
json = models.JSONField(null=True)
7273
json_unsmoothed = models.JSONField(null=True)
7374
polar_route_version = models.CharField(max_length=60, null=True)
75+
tags = TaggableManager(blank=True, help_text="Tags for route")
7476

7577

7678
class Job(models.Model):

polarrouteserver/route_api/serializers.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from rest_framework import serializers
22
from rest_framework.reverse import reverse
33
from celery.result import AsyncResult
4+
from taggit.serializers import TaggitSerializer, TagListSerializerField
45

56
from .models import Mesh, Vehicle, Route, Job, Location
67
from polarrouteserver.celery import app
@@ -100,7 +101,9 @@ class Meta:
100101
vessel_type = serializers.CharField()
101102

102103

103-
class RouteSerializer(serializers.ModelSerializer):
104+
class RouteSerializer(TaggitSerializer, serializers.ModelSerializer):
105+
tags = TagListSerializerField()
106+
104107
class Meta:
105108
model = Route
106109
fields = [
@@ -118,6 +121,7 @@ class Meta:
118121
"mesh",
119122
"requested",
120123
"calculated",
124+
"tags",
121125
]
122126

123127
def _extract_routes_by_type(self, route_data, route_type):
@@ -282,6 +286,7 @@ def to_representation(self, instance):
282286
result = {
283287
"routes": available_routes,
284288
"polarrouteserver-version": polarrouteserver_version,
289+
"tags": data.get("tags", []),
285290
}
286291

287292
# Add error if no routes available

polarrouteserver/route_api/views.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from datetime import datetime
33

44
from celery.result import AsyncResult
5+
from django.contrib.contenttypes.models import ContentType
56
from drf_spectacular.utils import (
67
extend_schema,
78
extend_schema_view,
@@ -13,6 +14,7 @@
1314
from rest_framework.views import APIView
1415
from rest_framework.reverse import reverse
1516
from rest_framework import serializers, viewsets
17+
from taggit.models import TaggedItem
1618

1719
from polar_route.config_validation.config_validator import validate_vessel_config
1820
from polarrouteserver.version import __version__ as polarrouteserver_version
@@ -318,6 +320,12 @@ class RouteRequestView(LoggingMixin, ResponseMixin, GenericAPIView):
318320
default=False,
319321
help_text="If true, forces recalculation even if an existing route is found.",
320322
),
323+
"tags": serializers.ListField(
324+
child=serializers.CharField(max_length=50),
325+
required=False,
326+
allow_null=True,
327+
help_text="Optional tags for route (e.g., ['archive', 'SD056']). Can also accept a single string or comma-separated string.",
328+
),
321329
},
322330
),
323331
responses={
@@ -350,6 +358,7 @@ def post(self, request):
350358
end_name = data.get("end_name", None)
351359
custom_mesh_id = data.get("mesh_id", None)
352360
force_new_route = data.get("force_new_route", False)
361+
tags = data.get("tags", None)
353362

354363
if custom_mesh_id:
355364
try:
@@ -409,6 +418,24 @@ def post(self, request):
409418
end_name=end_name,
410419
)
411420

421+
# Add tags if provided
422+
if tags:
423+
# Handle both string and list inputs
424+
if isinstance(tags, str):
425+
# If it's a string, split by comma and strip whitespace
426+
tags_list = [t.strip() for t in tags.split(",") if t.strip()]
427+
elif isinstance(tags, list):
428+
tags_list = [str(t).strip() for t in tags if str(t).strip()]
429+
else:
430+
tags_list = []
431+
432+
logger.info(f"Adding tags to route {route.id}: {tags_list}")
433+
if tags_list:
434+
route.tags.add(*tags_list)
435+
logger.info(
436+
f"Route {route.id} now has tags: {[tag.name for tag in route.tags.all()]}"
437+
)
438+
412439
# Start the task calculation
413440
task = optimise_route.delay(
414441
route.id, backup_mesh_ids=[mesh.id for mesh in meshes[1:]]
@@ -525,10 +552,32 @@ def get(self, request):
525552
}
526553
)
527554

555+
# Get route IDs for tag lookup
556+
route_ids = [route["id"] for route in routes_recent]
557+
558+
# Get all tags for routes in one query
559+
route_tags = {}
560+
if route_ids:
561+
content_type = ContentType.objects.get_for_model(Route)
562+
tagged_items = (
563+
TaggedItem.objects.filter(
564+
content_type=content_type, object_id__in=route_ids
565+
)
566+
.select_related("tag")
567+
.values("object_id", "tag__name")
568+
)
569+
570+
for item in tagged_items:
571+
route_id = int(item["object_id"])
572+
if route_id not in route_tags:
573+
route_tags[route_id] = []
574+
route_tags[route_id].append(item["tag__name"])
575+
528576
routes_data = []
529577
for route in routes_recent:
578+
job_id = route.get("job__id")
530579
status = self._get_celery_task_status(
531-
route["job__id"], route["calculated"], route["info"]
580+
job_id, route["calculated"], route["info"]
532581
)
533582

534583
# Build lightweight route data
@@ -551,12 +600,13 @@ def get(self, request):
551600
"route_url": reverse(
552601
"route_detail", args=[route["id"]], request=request
553602
),
603+
"tags": route_tags.get(route["id"], []),
554604
}
555605

556-
if route["job__id"]:
557-
route_data["job_id"] = route["job__id"]
606+
if job_id:
607+
route_data["job_id"] = job_id
558608
route_data["job_status_url"] = reverse(
559-
"job_detail", args=[route["job__id"]], request=request
609+
"job_detail", args=[job_id], request=request
560610
)
561611

562612
# Add minimal mesh info without loading the heavy JSON

polarrouteserver/settings/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"rest_framework",
9292
"drf_spectacular",
9393
"drf_spectacular_sidecar",
94+
"taggit",
9495
"polarrouteserver.route_api",
9596
"corsheaders",
9697
]
@@ -127,6 +128,8 @@
127128
"AUTHENTICATION_WHITELIST": [],
128129
}
129130

131+
TAGGIT_CASE_INSENSITIVE = True
132+
130133
ROOT_URLCONF = "polarrouteserver.urls"
131134

132135
TEMPLATES = [

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies = [
2020
"django-celery-results",
2121
"django-cors-headers",
2222
"django-rest-framework",
23+
"django-taggit",
2324
"drf-spectacular[sidecar]",
2425
"haversine",
2526
"polar-route==1.0.0",

request_route/request_route.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def request_route(
8787
num_requests: int = 10,
8888
force_new_route: bool = False,
8989
mesh_id: int = None,
90+
tags: list = None,
9091
) -> str:
9192
"""Requests a route from polarRouteServer, monitors job status until complete, then retrieves route data.
9293
@@ -98,6 +99,7 @@ def request_route(
9899
num_requests (int, optional): Max number of status requests before giving up. Defaults to 10.
99100
force_new_route (bool, optional): Force recalculation of an already existing route. Default: False.
100101
mesh_id (int, optional): Custom mesh ID to use for route calculation. Default: None.
102+
tags (list, optional): Tags to assign to the route. Default: None.
101103
102104
Raises:
103105
Exception: If no status URL is returned.
@@ -122,6 +124,7 @@ def request_route(
122124
"end_name": end.name,
123125
"force_new_route": force_new_route,
124126
"mesh_id": mesh_id,
127+
"tags": tags,
125128
},
126129
).encode("utf-8"),
127130
)
@@ -275,6 +278,13 @@ def parse_args():
275278
action="store_true",
276279
help="Force polarRouteServer to create a new route even if one is already available.",
277280
)
281+
parser.add_argument(
282+
"-t",
283+
"--tags",
284+
type=str,
285+
nargs="*",
286+
help="Tags to assign to the route (e.g., 'archive' 'SD056'). Can specify multiple tags separated by spaces.",
287+
)
278288
parser.add_argument(
279289
"-o",
280290
"--output",
@@ -296,6 +306,7 @@ def main():
296306
status_update_delay=args.delay,
297307
force_new_route=args.force,
298308
mesh_id=args.meshid,
309+
tags=args.tags,
299310
)
300311

301312
if route is None:

0 commit comments

Comments
 (0)