From ffc88644f03a1e2d3bf7dcf54ff237ea1387ce52 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Fri, 5 Jun 2026 15:55:36 -0500 Subject: [PATCH 01/24] add setting for max concurrent mosaic jobs --- ohmg/conf/management/commands/configure-services.py | 2 +- ohmg/conf/settings.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ohmg/conf/management/commands/configure-services.py b/ohmg/conf/management/commands/configure-services.py index 270da862..ce181973 100644 --- a/ohmg/conf/management/commands/configure-services.py +++ b/ohmg/conf/management/commands/configure-services.py @@ -220,7 +220,7 @@ def generate_celery_mosaic_service(self, state_path: Path): --statedb={str(state_path.resolve())}/celery_mosaic_worker.state \\ --loglevel=INFO \\ --logfile={log_dir}/celery_mosaic.log \\ - --concurrency=1 -n worker2@%h + --concurrency={settings.MAX_CONCURRENT_MOSAIC_JOBS} -n worker2@%h Restart=always [Install] diff --git a/ohmg/conf/settings.py b/ohmg/conf/settings.py index 9b64e0a7..9505f1e2 100644 --- a/ohmg/conf/settings.py +++ b/ohmg/conf/settings.py @@ -303,6 +303,8 @@ # this will be removed once Django is upgraded SWAP_COORDINATE_ORDER = ast.literal_eval(os.getenv("SWAP_COORDINATE_ORDER", "False")) +MAX_CONCURRENT_MOSAIC_JOBS = os.getenv("MAX_CONCURRENT_MOSAIC_JOBS", 1) + # CONFIGURE CELERY CELERY_BROKER_URL = os.getenv("BROKER_URL") CELERY_RESULT_BACKEND = "rpc://" @@ -327,6 +329,7 @@ "ohmg.georeference.tasks.delete_stale_sessions": {"queue": "background"}, "ohmg.georeference.tasks.delete_preview_vrts": {"queue": "background"}, "ohmg.georeference.tasks.cleanup_existing_tileset": {"queue": "background"}, + "ohmg.georeference.tasks.run_queued_mosaic_jobs": {"queue": "background"}, "ohmg.georeference.tasks.create_mosaic_cog": {"queue": "mosaic"}, "ohmg.georeference.tasks.create_mosaic_tileset": {"queue": "mosaic"}, } @@ -336,7 +339,11 @@ "remove_stale_sessions": { "task": "ohmg.georeference.tasks.delete_stale_sessions", "schedule": 60.0, - } + }, + "run_queued_mosaic_jobs": { + "task": "ohmg.georeference.tasks.run_queued_mosaic_jobs", + "schedule": 30.0, + }, } # note: this is app_label.ModelClass, From b4ec3ced09a279d3857b9c2b8ffe353b859d8292 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Fri, 5 Jun 2026 15:56:19 -0500 Subject: [PATCH 02/24] add Job model for task running --- ohmg/georeference/admin.py | 24 ++++ ohmg/georeference/migrations/0003_job.py | 35 +++++ ohmg/georeference/models.py | 171 +++++++++++++++++------ 3 files changed, 187 insertions(+), 43 deletions(-) create mode 100644 ohmg/georeference/migrations/0003_job.py diff --git a/ohmg/georeference/admin.py b/ohmg/georeference/admin.py index ee4e4c97..f309b5d8 100644 --- a/ohmg/georeference/admin.py +++ b/ohmg/georeference/admin.py @@ -4,6 +4,7 @@ GCP, GCPGroup, GeorefSession, + Job, PrepSession, SessionLock, ) @@ -55,3 +56,26 @@ class SessionLockAdmin(admin.ModelAdmin): admin.site.register(SessionLock, SessionLockAdmin) + + +@admin.action(description="Enqueue selected jobs") +def enqueue_job(modeladmin, request, queryset): + for obj in queryset.all(): + obj.enqueue() + + +class JobAdmin(admin.ModelAdmin): + readonly_fields = ( + "stage", + "date_created", + "date_queued", + "date_started", + "date_ended", + "run_duration", + ) + list_display = ("operation", "target", "stage", "message", "run_duration") + list_filter = ("operation", "stage") + actions = [enqueue_job] + + +admin.site.register(Job, JobAdmin) diff --git a/ohmg/georeference/migrations/0003_job.py b/ohmg/georeference/migrations/0003_job.py new file mode 100644 index 00000000..01aee1c7 --- /dev/null +++ b/ohmg/georeference/migrations/0003_job.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.27 on 2026-06-05 15:28 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0012_layerset_xyz_tiles_prefix'), + ('contenttypes', '0002_remove_content_type_name'), + ('georeference', '0002_sessionbase_map'), + ] + + operations = [ + migrations.CreateModel( + name='Job', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_created', models.DateTimeField(default=django.utils.timezone.now)), + ('date_queued', models.DateTimeField(blank=True, null=True)), + ('date_started', models.DateTimeField(blank=True, null=True)), + ('date_ended', models.DateTimeField(blank=True, null=True)), + ('operation', models.CharField(choices=[('load_documents', 'load_documents'), ('split_document', 'split_document'), ('region_to_cog', 'region_to_cog'), ('layerset_to_cog', 'layerset_to_cog'), ('layerset_to_xyz', 'layerset_to_xyz')], max_length=25)), + ('stage', models.CharField(blank=True, choices=[('queued', 'queued'), ('running', 'running'), ('completed', 'completed'), ('errored', 'errored')], max_length=25, null=True)), + ('run_duration', models.IntegerField(blank=True, null=True)), + ('target_id', models.PositiveIntegerField()), + ('data', models.JSONField(blank=True, default=dict)), + ('message', models.CharField(blank=True, max_length=255, null=True)), + ('map', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.map')), + ('target_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ], + ), + ] diff --git a/ohmg/georeference/models.py b/ohmg/georeference/models.py index 6b84b34b..f91eadf0 100644 --- a/ohmg/georeference/models.py +++ b/ohmg/georeference/models.py @@ -20,16 +20,17 @@ from ohmg.core.models import ( Document, Layer, + LayerSet, Map, Region, RegionCategory, ) from ohmg.core.utils import ( - full_reverse, random_alnum, ) from ohmg.georeference.georeferencer import Georeferencer from ohmg.georeference.splitter import Splitter +from ohmg.georeference.tasks import create_mosaic_cog from .sessions import ( add_lock, @@ -213,11 +214,6 @@ def save_from_geojson(self, geojson, region, transformation=None): return group -def set_upload_location(instance, filename): - """this function has to return the location to upload the file""" - return os.path.join(f"{instance.type}s", filename) - - def get_default_session_data(session_type): """Return a dict of the keys/types for a sessions's data field. Also used for type-checking during validation.""" @@ -382,43 +378,6 @@ def extend_locks(self): for lock in self.locks.all(): lock.extend() - def serialize(self): - # handle the non- js-serializable attributes - doc_id, layer_alt, d_create, d_mod, d_run = None, None, None, None, None - d_run_d, d_run_t = None, None - if self.doc: - doc_id = self.doc.pk - if self.lyr: - layer_alt = self.lyr.pk - if self.date_created: - d_create = self.date_created.strftime("%Y-%m-%d - %H:%M") - if self.date_modified: - d_mod = self.date_modified.strftime("%Y-%m-%d - %H:%M") - if self.date_run: - d_run = self.date_run.strftime("%Y-%m-%d - %H:%M") - d_run_d = self.date_run.strftime("%Y-%m-%d") - d_run_t = self.date_run.strftime("%H:%M") - - return { - "id": self.pk, - "type": self.get_type_display(), - "document": doc_id, - "layer": layer_alt, - "stage": self.stage, - "status": self.status, - "note": self.note, - "data": self.data, - "user": { - "name": self.user.username, - "profile": full_reverse("profile_detail", args=(self.user.username,)), - }, - "date_created": d_create, - "date_modified": d_mod, - "date_run": d_run, - "date_run_date": d_run_d, - "date_run_time": d_run_t, - } - def update_stage(self, stage, save=True): self.stage = stage logger.info(f"{self.__str__()} | stage: {self.stage}") @@ -801,3 +760,129 @@ def __str__(self): def extend(self): self.expiration += timedelta(seconds=settings.GEOREFERENCE_SESSION_LENGTH) self.save() + + +JOB_OPERATIONS = ( + ("load_documents", "load_documents"), + ("split_document", "split_document"), + ("region_to_cog", "region_to_cog"), + ("layerset_to_cog", "layerset_to_cog"), + ("layerset_to_xyz", "layerset_to_xyz"), +) + +JOB_STAGES = ( + ("queued", "queued"), + ("running", "running"), + ("completed", "completed"), + ("errored", "errored"), +) + + +class Job(models.Model): + """Submits and tracks backend operations.""" + + date_created = models.DateTimeField( + default=timezone.now, + ) + date_queued = models.DateTimeField(blank=True, null=True) + date_started = models.DateTimeField(blank=True, null=True) + date_ended = models.DateTimeField(blank=True, null=True) + operation = models.CharField( + max_length=25, + choices=JOB_OPERATIONS, + ) + stage = models.CharField( + max_length=25, + choices=JOB_STAGES, + blank=True, + null=True, + ) + run_duration = models.IntegerField( + blank=True, + null=True, + ) + target_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + target_id = models.PositiveIntegerField() + target = GenericForeignKey("target_type", "target_id") + data = models.JSONField( + default=dict, + blank=True, + ) + map = models.ForeignKey( + Map, + models.SET_NULL, + null=True, + blank=True, + ) + message = models.CharField( + blank=True, + null=True, + max_length=255, + ) + + def validate(self): + """Test the requested operation against the attached resource + and the input data. Return dict with valid bool and message.""" + + valid = True + message = "--" + if self.operation == "layerset_to_cog": + if not isinstance(self.target, LayerSet): + valid = False + message = f"invalid target {self.target} for {self.operation} job" + else: + valid = False + message = "invalid operation (or operation not yet supported)" + + return {"success": valid, "message": message} + + def run(self): + """Run the operation, there is bespoke logic here for each operation.""" + + validation = self.validate() + if not validation["success"]: + self.stage = "errored" + self.message = validation["message"] + self.save() + return + + self.start() + if self.operation == "layerset_to_cog": + create_mosaic_cog.delay(self.target.pk, self.pk) + + def enqueue(self): + self.stage = "queued" + self.date_queued = timezone.now() + self.date_started = None + self.date_ended = None + self.run_duration = None + self.save() + + def start(self): + self.stage = "running" + self.date_started = timezone.now() + self.save() + + def end(self, success: bool = True, message: str = ""): + self.date_ended = timezone.now() + self.message = message + self.stage = "completed" if success else "errored" + elapsed_delta = self.date_ended - self.date_started + self.run_duration = elapsed_delta.seconds + self.save() + + def save(self, *args, **kwargs): + if not self.map: + if isinstance( + self.target, + ( + Document, + Region, + Layer, + LayerSet, + ), + ): + self.map = self.target.map + if isinstance(self.target, Map): + self.maps = self.target + return super(Job, self).save(*args, **kwargs) From 31bc8d6c07d3ad0c2568e659b94514782971381d Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Fri, 5 Jun 2026 15:58:43 -0500 Subject: [PATCH 03/24] implement scheduling for queued mosaic jobs; fix imports --- ohmg/georeference/tasks.py | 52 ++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/ohmg/georeference/tasks.py b/ohmg/georeference/tasks.py index 8d0f80c1..39f34748 100644 --- a/ohmg/georeference/tasks.py +++ b/ohmg/georeference/tasks.py @@ -9,17 +9,13 @@ from ohmg.core.models import LayerSet from ohmg.core.utils.s3 import get_boto3_s3_client -from .models import ( - GeorefSession, - PrepSession, -) -from .sessions import delete_expired_session_locks - logger = logging.getLogger(__name__) @app.task def run_preparation_session(sessionid): + from .models import PrepSession + session = PrepSession.objects.get(pk=sessionid) session.run() return session.pk @@ -27,6 +23,8 @@ def run_preparation_session(sessionid): @app.task def bulk_run_preparation_sessions(sessionids): + from .models import PrepSession + for sessionid in sessionids: session = PrepSession.objects.get(pk=sessionid) session.run() @@ -35,6 +33,8 @@ def bulk_run_preparation_sessions(sessionids): @app.task def run_georeference_session(sessionid): + from .models import GeorefSession + session = GeorefSession.objects.get(pk=sessionid) session.run() return session.pk @@ -42,6 +42,8 @@ def run_georeference_session(sessionid): @app.task def delete_stale_sessions(): + from .sessions import delete_expired_session_locks + delete_expired_session_locks() @@ -52,16 +54,26 @@ def delete_preview_vrts(id): @app.task -def create_mosaic_cog(layersetid): - from ohmg.georeference.mosaicker import Mosaicker +def create_mosaic_cog(layersetid, jobid=None): + from .models import Job + from .mosaicker import Mosaicker + logger.debug("begin create mosaic task") try: layerset = LayerSet.objects.get(pk=layersetid) except LayerSet.DoesNotExist: logger.warning(f"LayerSet does not exist: {layersetid}. Cancelling mosaic creation.") - m = Mosaicker() - m.generate_cog(layerset) - m.cleanup_files() + try: + m = Mosaicker() + success = True + message = m.generate_cog(layerset) + m.cleanup_files() + except Exception as e: + success = False + message = e + + if jobid: + Job.objects.get(pk=jobid).end(success=success, message=message) @app.task @@ -94,3 +106,21 @@ def cleanup_existing_tileset(prefix): ) except FileNotFoundError: pass + + +@app.task +def run_queued_mosaic_jobs(): + from ohmg.georeference.models import Job + + max_jobs_ct = settings.MAX_CONCURRENT_MOSAIC_JOBS + + mosaic_jobs = Job.objects.filter(operation__in=["layerset_to_cog", "layerset_to_xyz"]) + + ## if there are already max mosaic jobs running, don't start another one + running_jobs_ct = mosaic_jobs.filter(stage="running").count() + spots_left = max_jobs_ct - running_jobs_ct + if spots_left: + queued_jobs = mosaic_jobs.filter(stage="queued").order_by("date_queued") + for job in queued_jobs[:spots_left]: + logger.info(f"starting queued job {job.pk}: {job.operation}, {job.target}") + job.run() From 2e180f46eb48f0e9134ec0e457ff783b22ac4d01 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Fri, 5 Jun 2026 17:04:48 -0500 Subject: [PATCH 04/24] increase mosaic queue polling interval to 60 sec --- ohmg/conf/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ohmg/conf/settings.py b/ohmg/conf/settings.py index 9505f1e2..9855e057 100644 --- a/ohmg/conf/settings.py +++ b/ohmg/conf/settings.py @@ -342,7 +342,7 @@ }, "run_queued_mosaic_jobs": { "task": "ohmg.georeference.tasks.run_queued_mosaic_jobs", - "schedule": 30.0, + "schedule": 60.0, }, } From f0e1f6cc7b5e7e8cb63cbe281b7a0c3e5db9ce50 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Fri, 5 Jun 2026 17:06:43 -0500 Subject: [PATCH 05/24] cleanup logging and admin interface --- ohmg/georeference/admin.py | 14 ++++++++++++-- ohmg/georeference/models.py | 2 +- ohmg/georeference/mosaicker.py | 10 +++------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/ohmg/georeference/admin.py b/ohmg/georeference/admin.py index f309b5d8..a5c48cd8 100644 --- a/ohmg/georeference/admin.py +++ b/ohmg/georeference/admin.py @@ -73,8 +73,18 @@ class JobAdmin(admin.ModelAdmin): "date_ended", "run_duration", ) - list_display = ("operation", "target", "stage", "message", "run_duration") - list_filter = ("operation", "stage") + list_display = ( + "target", + "operation", + "stage", + "date_created", + "date_queued", + "date_started", + "date_ended", + "message", + "run_duration", + ) + list_filter = ("operation", "stage", "map") actions = [enqueue_job] diff --git a/ohmg/georeference/models.py b/ohmg/georeference/models.py index f91eadf0..c4f5e584 100644 --- a/ohmg/georeference/models.py +++ b/ohmg/georeference/models.py @@ -825,7 +825,7 @@ def validate(self): and the input data. Return dict with valid bool and message.""" valid = True - message = "--" + message = None if self.operation == "layerset_to_cog": if not isinstance(self.target, LayerSet): valid = False diff --git a/ohmg/georeference/mosaicker.py b/ohmg/georeference/mosaicker.py index 323ab5eb..3e271f44 100644 --- a/ohmg/georeference/mosaicker.py +++ b/ohmg/georeference/mosaicker.py @@ -61,7 +61,7 @@ def generate_mosaic_vrt(self, layerset) -> VRTHandler: for feature in multimask_geojson["features"]: layer_name = feature["properties"]["layer"] - print(layer_name) + logger.debug(layer_name) try: layer = Layer.objects.get(slug=layer_name, region__document__map=layerset.map) except Layer.MultipleObjectsReturned: @@ -102,18 +102,16 @@ def generate_mosaic_vrt(self, layerset) -> VRTHandler: outputBounds=bounds, separate=False, ) - print("building vrt") + logger.info("building mosaic vrt") self.mosaic_vrt = VRTHandler(f"{layerset.map.identifier}-{layerset.category.slug}") trim_list = [str(i.get_path()) for i in self.trimmed_vrts] gdal.BuildVRT(str(self.mosaic_vrt.get_path()), trim_list, options=vo) def generate_cog(self, layerset: LayerSet): - start = datetime.now() - self.generate_mosaic_vrt(layerset) - print("building final geotiff") + logger.info("begin writing mosaic geotiff") to = gdal.TranslateOptions( format="COG", @@ -139,8 +137,6 @@ def generate_cog(self, layerset: LayerSet): layerset.save(set_tilejson=True) - print(f"completed - elapsed time: {datetime.now() - start}") - def generate_xyz_tiles( self, layerset: LayerSet, From b2d8782fac8eca642b63f3810f1a2ca6f46348c4 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Fri, 5 Jun 2026 17:08:03 -0500 Subject: [PATCH 06/24] auto-queue mosaic generation on multimask submit --- ohmg/core/models/layerset.py | 12 ++++++++++++ ohmg/core/views.py | 1 + 2 files changed, 13 insertions(+) diff --git a/ohmg/core/models/layerset.py b/ohmg/core/models/layerset.py index a61ecb5b..6ed91ede 100644 --- a/ohmg/core/models/layerset.py +++ b/ohmg/core/models/layerset.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Iterable, Union from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.contrib.gis.db import models from django.contrib.gis.geos import MultiPolygon, Polygon from django.contrib.postgres.fields import ArrayField @@ -146,6 +147,17 @@ def multimask_geojson(self) -> dict: fc["features"].append(mask_geojson) return fc + def queue_mosaic_cog(self) -> None: + from ohmg.georeference.models import Job + + j, created = Job.objects.get_or_create( + stage="queued", + operation="layerset_to_cog", + target_id=self.pk, + target_type=ContentType.objects.get_for_model(self), + ) + j.enqueue() + def save(self, set_tilejson: bool = False, *args, **kwargs): if self._state.adding is False: extents = self.get_layers().values_list("extent", flat=True) diff --git a/ohmg/core/views.py b/ohmg/core/views.py index baf30225..ced89a39 100644 --- a/ohmg/core/views.py +++ b/ohmg/core/views.py @@ -476,6 +476,7 @@ def post(self, request): layer.mask = None layer.save(set_extent=False, skip_map_lookup_update=True) layerset.map.update_item_lookup() + layerset.queue_mosaic_cog() return JsonResponseSuccess() From 234009bcbb860aea0e04bd5cec3629ed6e46a1d4 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Mon, 22 Jun 2026 09:44:23 -0500 Subject: [PATCH 07/24] rename command for consistency --- .../management/commands/{sessions.py => session.py} | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) rename ohmg/georeference/management/commands/{sessions.py => session.py} (90%) diff --git a/ohmg/georeference/management/commands/sessions.py b/ohmg/georeference/management/commands/session.py similarity index 90% rename from ohmg/georeference/management/commands/sessions.py rename to ohmg/georeference/management/commands/session.py index 1f16808e..4c44460a 100644 --- a/ohmg/georeference/management/commands/sessions.py +++ b/ohmg/georeference/management/commands/session.py @@ -1,10 +1,8 @@ +import json + from django.core.management.base import BaseCommand -from ohmg.georeference.models import ( - GeorefSession, - PrepSession, - SessionBase -) +from ohmg.georeference.models import GeorefSession, PrepSession, SessionBase from ohmg.georeference.sessions import delete_expired_session_locks @@ -19,6 +17,7 @@ def add_arguments(self, parser): "undo", "list", "delete-expired", + "data", ], help="specify the operation to carry out", ) @@ -54,7 +53,7 @@ def _model_from_type(self, session_type): def handle(self, *args, **options): operation = options["operation"] - if operation in ["run", "undo"]: + if operation in ["run", "undo", "data"]: bs = SessionBase.objects.get(pk=options["pk"]) model = self._model_from_type(bs.type) session = model.objects.get(pk=options["pk"]) @@ -63,6 +62,8 @@ def handle(self, *args, **options): session.run() elif operation == "undo": session.undo() + elif operation == "data": + print(json.dumps(session.data, indent=1)) elif operation == "list": if options["type"]: From db0aabf58bb5a335b13b755db03dd7300ea53377 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Tue, 23 Jun 2026 09:52:05 -0500 Subject: [PATCH 08/24] add creation date fields to layerset --- ohmg/api/schemas.py | 25 +++++++++++++ ohmg/conf/http.py | 6 +-- ohmg/core/admin.py | 8 ++++ ...0014_alter_permissions_options_and_more.py | 32 ++++++++++++++++ ohmg/core/models/layerset.py | 37 ++++++++++++++++++- ohmg/core/models/permissions.py | 6 ++- ohmg/core/views.py | 34 ++++++++++++++++- 7 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 ohmg/core/migrations/0014_alter_permissions_options_and_more.py diff --git a/ohmg/api/schemas.py b/ohmg/api/schemas.py index f4b5d916..95d5855d 100644 --- a/ohmg/api/schemas.py +++ b/ohmg/api/schemas.py @@ -476,6 +476,7 @@ def resolve_urls(obj): class LayerSetSchema(Schema): + pk: int id: str name: str map_id: str @@ -486,7 +487,10 @@ class LayerSetSchema(Schema): extent: Optional[tuple] multimask_extent: Optional[tuple] mosaic_cog_url: Optional[str] + mosaic_geotiff_date: Optional[str] xyz_tiles_url: Optional[str] + xyz_tiles_date: Optional[str] + multimask_date: Optional[float] @staticmethod def resolve_id(obj): @@ -504,6 +508,27 @@ def resolve_layers_masked_ct(obj): def resolve_name(obj): return str(obj.category) + @staticmethod + def resolve_mosaic_geotiff_date(obj): + try: + return obj.mosaic_geotiff_date.timestamp() + except AttributeError: + return None + + @staticmethod + def resolve_xyz_tiles_date(obj): + try: + return obj.xyz_tiles_date.timestamp() + except AttributeError: + return None + + @staticmethod + def resolve_multimask_date(obj): + try: + return obj.multimask_date.timestamp() + except AttributeError: + return None + class LayerSetDisplaySchema(Schema): map_id: str diff --git a/ohmg/conf/http.py b/ohmg/conf/http.py index 128d8d30..99db8a2b 100644 --- a/ohmg/conf/http.py +++ b/ohmg/conf/http.py @@ -38,7 +38,7 @@ def user_info_from_request(request): user_info["api_keys"] = user.api_keys user_info["is_authenticated"] = True user_info["is_staff"] = user.is_staff - user_info["perms"] = list(user.get_all_permissions()) + user_info["perms"] = sorted(list(user.get_all_permissions())) else: user_info = { "is_authenticated": False, @@ -90,7 +90,7 @@ def __init__(self, message="object not found"): class JsonResponseUnauthorized(JsonResponse): def __init__(self, message="unauthorized"): - self.status_code = HTTPStatus.NOT_FOUND + self.status_code = HTTPStatus.UNAUTHORIZED super().__init__( { "success": False, @@ -101,7 +101,7 @@ def __init__(self, message="unauthorized"): class JsonResponseBadRequest(JsonResponse): def __init__(self, message="bad request"): - self.status_code = HTTPStatus.NOT_FOUND + self.status_code = HTTPStatus.BAD_REQUEST super().__init__( { "success": False, diff --git a/ohmg/core/admin.py b/ohmg/core/admin.py index 5ec5a4a5..a99cd274 100644 --- a/ohmg/core/admin.py +++ b/ohmg/core/admin.py @@ -61,12 +61,20 @@ class LayerAdmin(admin.ModelAdmin): class LayerSetAdmin(admin.ModelAdmin): + list_display = ( + "map", + "category", + "mosaic_geotiff_date", + "xyz_tiles_date", + ) raw_id_fields = ("map",) readonly_fields = ( "layer_display_list", "extent", "multimask_extent", "xyz_tiles_url", + "mosaic_geotiff_date", + "xyz_tiles_date", ) search_fields = ("map__title",) list_filter = ("category",) diff --git a/ohmg/core/migrations/0014_alter_permissions_options_and_more.py b/ohmg/core/migrations/0014_alter_permissions_options_and_more.py new file mode 100644 index 00000000..e692984f --- /dev/null +++ b/ohmg/core/migrations/0014_alter_permissions_options_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.27 on 2026-06-23 09:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0013_permissions'), + ] + + operations = [ + migrations.AlterModelOptions( + name='permissions', + options={'default_permissions': (), 'managed': False, 'permissions': (('use_helmert', 'Can use Helmert transformation'), ('queue_mosaic_cog', 'Can queue creation of mosaic COG'), ('queue_mosaic_xyz', 'Can queue creation of mosaic XYZ tileset'))}, + ), + migrations.AddField( + model_name='layerset', + name='mosaic_geotiff_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='layerset', + name='multimask_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='layerset', + name='xyz_tiles_date', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/ohmg/core/models/layerset.py b/ohmg/core/models/layerset.py index 6ed91ede..de7f80f4 100644 --- a/ohmg/core/models/layerset.py +++ b/ohmg/core/models/layerset.py @@ -53,6 +53,7 @@ class Meta: blank=True, max_length=255, ) + mosaic_geotiff_date = models.DateTimeField(blank=True, null=True) mosaic_json = models.FileField( upload_to="mosaics", null=True, @@ -70,7 +71,9 @@ class Meta: blank=True, null=True, ) + xyz_tiles_date = models.DateTimeField(blank=True, null=True) tilejson = models.JSONField(null=True, blank=True) + multimask_date = models.DateTimeField(blank=True, null=True) def __str__(self): return f"{self.map} - {self.category}" @@ -98,6 +101,17 @@ def xyz_tiles_url(self): else: return None + @property + def xyz_tiles_download_url(self): + if self.xyz_tiles_prefix: + if settings.ENABLE_S3_STORAGE: + base_url = f"{settings.AWS_S3_ENDPOINT_URL}/{settings.AWS_STORAGE_BUCKET_NAME}" + else: + base_url = f"{settings.SITEURL.rstrip('/')}{settings.MEDIA_URL}" + return f"{base_url.rstrip('/')}/{self.xyz_tiles_prefix}/{self.mosaic_xyz_tiles_date}" + else: + return None + @cached_property def centroid(self): return Polygon.from_bbox(self.extent).centroid @@ -147,7 +161,11 @@ def multimask_geojson(self) -> dict: fc["features"].append(mask_geojson) return fc - def queue_mosaic_cog(self) -> None: + def queue_mosaic_cog(self) -> int: + """Creates a new job to generate a mosaic cog from this layerset. + If a job already exists for this action, it is re-queued. + + Returns the job id.""" from ohmg.georeference.models import Job j, created = Job.objects.get_or_create( @@ -157,6 +175,23 @@ def queue_mosaic_cog(self) -> None: target_type=ContentType.objects.get_for_model(self), ) j.enqueue() + return j.pk + + def queue_mosaic_tileset(self) -> int: + """Creates a new job to generate a XYZ tileset from this layerset. + If a job already exists for this action, it is re-queued. + + Returns the job id.""" + from ohmg.georeference.models import Job + + j, created = Job.objects.get_or_create( + stage="queued", + operation="layerset_to_xyz", + target_id=self.pk, + target_type=ContentType.objects.get_for_model(self), + ) + j.enqueue() + return j.pk def save(self, set_tilejson: bool = False, *args, **kwargs): if self._state.adding is False: diff --git a/ohmg/core/models/permissions.py b/ohmg/core/models/permissions.py index 2b68bbb3..912ba342 100644 --- a/ohmg/core/models/permissions.py +++ b/ohmg/core/models/permissions.py @@ -16,4 +16,8 @@ class Meta: default_permissions = () # disable "add", "change", "delete" # and "view" default permissions - permissions = (("use_helmert", "Can use Helmert transformation"),) + permissions = ( + ("use_helmert", "Can use Helmert transformation"), + ("queue_mosaic_cog", "Can queue creation of mosaic COG"), + ("queue_mosaic_xyz", "Can queue creation of mosaic XYZ tileset"), + ) diff --git a/ohmg/core/views.py b/ohmg/core/views.py index ced89a39..fe6fee49 100644 --- a/ohmg/core/views.py +++ b/ohmg/core/views.py @@ -1,5 +1,6 @@ import json import logging +from datetime import datetime from urllib.parse import quote from django.conf import settings @@ -25,6 +26,7 @@ JsonResponseFail, JsonResponseNotFound, JsonResponseSuccess, + JsonResponseUnauthorized, generate_ohmg_context, validate_post_request, ) @@ -415,7 +417,16 @@ def get(self, request, pk, derivative, resource=""): class LayerSetView(View): - @method_decorator(validate_post_request(operations=["bulk-classify-layers", "set-mask"])) + @method_decorator( + validate_post_request( + operations=[ + "bulk-classify-layers", + "set-mask", + "queue-cog-creation", + "queue-tileset-creation", + ] + ) + ) def post(self, request): body = json.loads(request.body) operation = body.get("operation") @@ -475,10 +486,29 @@ def post(self, request): logger.debug(f"removing mask from layer {layer.slug} ({layer.pk})") layer.mask = None layer.save(set_extent=False, skip_map_lookup_update=True) + layerset.multimask_date = datetime.now() + layerset.save() layerset.map.update_item_lookup() - layerset.queue_mosaic_cog() return JsonResponseSuccess() + if operation == "queue-cog-creation": + if not request.user.has_perm("core.queue_mosaic_cog"): + return JsonResponseUnauthorized() + layerset = LayerSet.objects.get( + map_id=payload["map-id"], category__slug=payload["category"] + ) + job_id = layerset.queue_mosaic_cog() + return JsonResponseSuccess(payload={"job": job_id}) + + if operation == "queue-tileset-creation": + if not request.user.has_perm("core.queue_mosaic_xyz"): + return JsonResponseUnauthorized() + layerset = LayerSet.objects.get( + map_id=payload["map-id"], category__slug=payload["category"] + ) + job_id = layerset.queue_mosaic_tileset() + return JsonResponseSuccess(payload={"job": job_id}) + class LayersetDerivativeView(View): def get(self, request, mapid, category, derivative): From e5c68d68a2215da05d422d9f4939bc3cea68d7a1 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Tue, 23 Jun 2026 09:53:05 -0500 Subject: [PATCH 09/24] update mosaic generation management --- .../management/commands/mosaic.py | 2 +- ohmg/georeference/models.py | 6 ++++-- ohmg/georeference/mosaicker.py | 4 +++- ohmg/georeference/tasks.py | 21 +++++++++++++------ 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/ohmg/georeference/management/commands/mosaic.py b/ohmg/georeference/management/commands/mosaic.py index 979a86e5..3aa51287 100644 --- a/ohmg/georeference/management/commands/mosaic.py +++ b/ohmg/georeference/management/commands/mosaic.py @@ -59,7 +59,7 @@ def handle(self, *args, **options): if options.background: create_mosaic_tileset.delay(ls.pk) else: - m.generate_xyz_tiles(ls, use_multiprocessing=options.multiprocessing) + m.generate_tileset(ls, use_multiprocessing=options.multiprocessing) m.cleanup_files() if options.operation == "generate-cog": diff --git a/ohmg/georeference/models.py b/ohmg/georeference/models.py index c4f5e584..196d6c62 100644 --- a/ohmg/georeference/models.py +++ b/ohmg/georeference/models.py @@ -30,7 +30,7 @@ ) from ohmg.georeference.georeferencer import Georeferencer from ohmg.georeference.splitter import Splitter -from ohmg.georeference.tasks import create_mosaic_cog +from ohmg.georeference.tasks import create_mosaic_cog, create_mosaic_tileset from .sessions import ( add_lock, @@ -826,7 +826,7 @@ def validate(self): valid = True message = None - if self.operation == "layerset_to_cog": + if self.operation in ["layerset_to_cog", "layerset_to_xyz"]: if not isinstance(self.target, LayerSet): valid = False message = f"invalid target {self.target} for {self.operation} job" @@ -849,6 +849,8 @@ def run(self): self.start() if self.operation == "layerset_to_cog": create_mosaic_cog.delay(self.target.pk, self.pk) + elif self.operation == "layerset_to_xyz": + create_mosaic_tileset.delay(self.target.pk, self.pk) def enqueue(self): self.stage = "queued" diff --git a/ohmg/georeference/mosaicker.py b/ohmg/georeference/mosaicker.py index 3e271f44..82a720ee 100644 --- a/ohmg/georeference/mosaicker.py +++ b/ohmg/georeference/mosaicker.py @@ -135,9 +135,10 @@ def generate_cog(self, layerset: LayerSet): if existing_file_name and storage.exists(name=existing_file_name): storage.delete(name=existing_file_name) + layerset.mosaic_geotiff_date = datetime.now() layerset.save(set_tilejson=True) - def generate_xyz_tiles( + def generate_tileset( self, layerset: LayerSet, min_zoom: int = 13, @@ -163,6 +164,7 @@ def generate_xyz_tiles( existing_tileset_prefix = layerset.xyz_tiles_prefix layerset.xyz_tiles_prefix = prefix + layerset.xyz_tiles_date = datetime.now() layerset.save() ## clean up existing tileset diff --git a/ohmg/georeference/tasks.py b/ohmg/georeference/tasks.py index 39f34748..44f61333 100644 --- a/ohmg/georeference/tasks.py +++ b/ohmg/georeference/tasks.py @@ -54,7 +54,7 @@ def delete_preview_vrts(id): @app.task -def create_mosaic_cog(layersetid, jobid=None): +def create_mosaic_cog(layersetid: int, jobid: int | None = None): from .models import Job from .mosaicker import Mosaicker @@ -77,16 +77,25 @@ def create_mosaic_cog(layersetid, jobid=None): @app.task -def create_mosaic_tileset(layersetid): - from ohmg.georeference.mosaicker import Mosaicker +def create_mosaic_tileset(layersetid: int, jobid: int | None = None): + from .models import Job + from .mosaicker import Mosaicker try: layerset = LayerSet.objects.get(pk=layersetid) except LayerSet.DoesNotExist: logger.warning(f"LayerSet does not exist: {layersetid}. Cancelling mosaic creation.") - m = Mosaicker() - m.generate_xyz_tiles(layerset) - m.cleanup_files() + try: + m = Mosaicker() + success = True + message = m.generate_tileset(layerset) + m.cleanup_files() + except Exception as e: + success = False + message = e + + if jobid: + Job.objects.get(pk=jobid).end(success=success, message=message) @app.task From 9d4e80e27515fa700503ab8918881d63bef3f811 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Tue, 23 Jun 2026 09:53:52 -0500 Subject: [PATCH 10/24] update mosaic derivatives section; add queue buttons --- .../src/components/map/Map.svelte | 12 +- .../components/map/MosaicDerivatives.svelte | 265 ++++++++++++++++++ .../src/components/map/MosaicDownload.svelte | 132 --------- .../src/components/resource/Details.svelte | 6 +- .../components/shared/DerivativeItem.svelte | 61 ++++ .../shared/DerivativeSubheader.svelte | 21 ++ .../shared/buttons/CopyableText.svelte | 44 +++ .../components/shared/cards/LayerCard.svelte | 10 +- .../shared/cards/SkippedCard.svelte | 4 +- .../svelte_components/src/lib/utils.js | 1 - 10 files changed, 403 insertions(+), 153 deletions(-) create mode 100644 ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte delete mode 100644 ohmg/frontend/svelte_components/src/components/map/MosaicDownload.svelte create mode 100644 ohmg/frontend/svelte_components/src/components/shared/DerivativeItem.svelte create mode 100644 ohmg/frontend/svelte_components/src/components/shared/DerivativeSubheader.svelte create mode 100644 ohmg/frontend/svelte_components/src/components/shared/buttons/CopyableText.svelte diff --git a/ohmg/frontend/svelte_components/src/components/map/Map.svelte b/ohmg/frontend/svelte_components/src/components/map/Map.svelte index 72dcbff5..2f3352da 100644 --- a/ohmg/frontend/svelte_components/src/components/map/Map.svelte +++ b/ohmg/frontend/svelte_components/src/components/map/Map.svelte @@ -26,7 +26,7 @@ import LoadingMask from '../shared/LoadingMask.svelte'; import MultimaskSection from './MultimaskSection.svelte' - import MosaicDownload from './MosaicDownload.svelte'; + import MosaicDerivatives from './MosaicDerivatives.svelte'; import Details from './Details.svelte'; import MapContributors from '../tables/MapContributors.svelte'; @@ -331,7 +331,6 @@ let regionToSetAsNonMap; let regionToSetAsMap; let regionToSkip; - let regionToUnskip; let undoGeorefLayerId; let processing = false; @@ -510,7 +509,7 @@ {#if mosaicSectionActiveTab == "preview"} {#key previewKey} @@ -528,10 +527,9 @@ bind:multimaskKey {userCanEdit} /> {:else if mosaicSectionActiveTab == "download"} - + mapId={MAP.identifier}/> {/if} @@ -751,7 +749,7 @@ bind:modalExtent bind:modalIsGeospatial bind:reinitModalMap - bind:regionToUnskip + bind:regionToSkip /> {/each} diff --git a/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte b/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte new file mode 100644 index 00000000..17b56e2f --- /dev/null +++ b/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte @@ -0,0 +1,265 @@ + + + {submitQueueRequest('queue-cog-creation')}} +> +

Submit mosaic COG generation to queue?

+
+ {submitQueueRequest('queue-tileset-creation')}} +> +

Submit tileset generation to queue?

+
+ +
+

+ Once layers have been trimmed in the MultiMask, a background process can be run + to combine them into a single mosaic file, which serves as a basis for downloads and web services. + If you see n/a below, the mosaic has not yet been created. You can still access + individual layers through the Georeferenced section, or view the mosaic in Allmaps + (powered by IIIF). +

+
+{#each layersets as ls} +{#if ls.layers.length >= 1} +

+ + {`${ls.name} (${ls.layers.length} layer${ls.layers.length > 1 ? 's' : ''})`} + + {#if ls.multimask_date} + + masks last updated: {ls.localeDateMasks} + + {/if} +

+
+ + + {#if ls.mosaic_cog_url} + {ls.mosaic_cog_url} + {/if} + + + {#if ls.xyz_tiles_archive} + {ls.xyz_tiles_archive} + {/if} + + + + {#if ls.tileJsonUrl} + + {/if} + + + {#if ls.mosaic_cog_url} + + {/if} + + + {#if ls.xyz_tiles_url} + + {/if} + + + {ls.iiifAnnoUrl} + + + + {#if ls.mosaic_cog_url} + {ls.ohmUrl} + + {/if} + + + {ls.allmapsUrl} + + +
+
+
+ + +
+
+{/if} +{/each} + + \ No newline at end of file diff --git a/ohmg/frontend/svelte_components/src/components/map/MosaicDownload.svelte b/ohmg/frontend/svelte_components/src/components/map/MosaicDownload.svelte deleted file mode 100644 index 399b5b4a..00000000 --- a/ohmg/frontend/svelte_components/src/components/map/MosaicDownload.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - -
-

- Once layers have been trimmed in the MultiMask, a background process can be run - to combine them into a single mosaic file, which serves as a basis for downloads and web services. - If you see n/a below, the mosaic has not yet been created. You can still access - individual layers through the Georeferenced section, or view the mosaic in Allmaps - (powered by IIIF). -

-
-{#each orderedLayersets as ls} -{#if ls.layers.length >= 1} -

{`${ls.name} (${ls.layers.length} layer${ls.layers.length > 1 ? 's' : ''})`}

-
-
TileJSON
-
- {#if ls.mosaic_cog_url} - {CONTEXT.site_url}map/{MAP.identifier}/{ls.id}/tilejson - {:else} - n/a - {/if} -
-
OpenHistoricalMap iD editor
-
{#if ls.mosaic_cog_url} - {CONTEXT.site_url}map/{MAP.identifier}/{ls.id}/ohm - {:else} - n/a - {/if}
-
Cloud-Optimized GeoTIFF
-
- {#if ls.mosaic_cog_url} - {CONTEXT.site_url}map/{MAP.identifier}/{ls.id}/cog - {:else} - n/a - {/if} -
- {#if ls.xyz_tiles_url} -
XYZ tileset
-
-
{`${ls.xyz_tiles_url}/{z}/{x}/{y}.png`}
-
- {/if} -
IIIF Georef AnnotationPage (JSON)
-
- {CONTEXT.site_url}iiif/mosaic/{MAP.identifier}/{ls.id}/?trim=true -
-
Allmaps
-
- https://viewer.allmaps.org/?url={encodeURIComponent( - `${CONTEXT.site_url}iiif/mosaic/${MAP.identifier}/${ls.id}/?trim=true`, - )} -
-
-{/if} -{/each} - - \ No newline at end of file diff --git a/ohmg/frontend/svelte_components/src/components/resource/Details.svelte b/ohmg/frontend/svelte_components/src/components/resource/Details.svelte index e0aff92f..56b08629 100644 --- a/ohmg/frontend/svelte_components/src/components/resource/Details.svelte +++ b/ohmg/frontend/svelte_components/src/components/resource/Details.svelte @@ -7,7 +7,7 @@ import ResourceDownloadSectionModal from '../shared/modals/ResourceDownloadSectionModal.svelte'; import { makeTitilerXYZUrl } from '../../lib/utils'; - import InfoModalButton from '../shared/buttons/InfoModalButton.svelte'; + import CopyableText from '../shared/buttons/CopyableText.svelte'; export let CONTEXT; export let RESOURCE; @@ -94,7 +94,7 @@
XYZ Tiles URL
{#if xyzUrl} -
{xyzUrl}
+ {:else} n/a {/if} @@ -102,7 +102,7 @@
OHM
{#if ohmUrl} - Open in OpenHistoricalMap iD editor {:else} diff --git a/ohmg/frontend/svelte_components/src/components/shared/DerivativeItem.svelte b/ohmg/frontend/svelte_components/src/components/shared/DerivativeItem.svelte new file mode 100644 index 00000000..cf58a5aa --- /dev/null +++ b/ohmg/frontend/svelte_components/src/components/shared/DerivativeItem.svelte @@ -0,0 +1,61 @@ + + + +
+ {title} + {#if dateTimestamp} + {new Date(dateTimestamp * 1000).toLocaleString()} + {:else if dateString} + {dateString} + {/if} +
+
+ + {naMessage} + +
+ + \ No newline at end of file diff --git a/ohmg/frontend/svelte_components/src/components/shared/DerivativeSubheader.svelte b/ohmg/frontend/svelte_components/src/components/shared/DerivativeSubheader.svelte new file mode 100644 index 00000000..0f9b1556 --- /dev/null +++ b/ohmg/frontend/svelte_components/src/components/shared/DerivativeSubheader.svelte @@ -0,0 +1,21 @@ + + +
{title}
+ + \ No newline at end of file diff --git a/ohmg/frontend/svelte_components/src/components/shared/buttons/CopyableText.svelte b/ohmg/frontend/svelte_components/src/components/shared/buttons/CopyableText.svelte new file mode 100644 index 00000000..6bc21287 --- /dev/null +++ b/ohmg/frontend/svelte_components/src/components/shared/buttons/CopyableText.svelte @@ -0,0 +1,44 @@ + + + +

The following text has been copied to your clipboard:

+

{text}

+
+ + + + \ No newline at end of file diff --git a/ohmg/frontend/svelte_components/src/components/shared/cards/LayerCard.svelte b/ohmg/frontend/svelte_components/src/components/shared/cards/LayerCard.svelte index 84f15a61..c7908bab 100644 --- a/ohmg/frontend/svelte_components/src/components/shared/cards/LayerCard.svelte +++ b/ohmg/frontend/svelte_components/src/components/shared/cards/LayerCard.svelte @@ -8,7 +8,7 @@ import Link from '../../base/Link.svelte'; import BaseCard from '../../base/Card.svelte'; - import { copyToClipboard } from '../../../lib/utils'; + import CopyableText from '../buttons/CopyableText.svelte'; export let CONTEXT; export let LAYERSET_CATEGORIES; @@ -107,13 +107,7 @@ TileJSON
  • - +
  • OpenHistoricalMap iD diff --git a/ohmg/frontend/svelte_components/src/components/shared/cards/SkippedCard.svelte b/ohmg/frontend/svelte_components/src/components/shared/cards/SkippedCard.svelte index bc680750..cfe269f7 100644 --- a/ohmg/frontend/svelte_components/src/components/shared/cards/SkippedCard.svelte +++ b/ohmg/frontend/svelte_components/src/components/shared/cards/SkippedCard.svelte @@ -12,7 +12,7 @@ export let modalExtent; export let modalIsGeospatial; export let reinitModalMap; - export let regionToUnskip; + export let regionToSkip; @@ -47,7 +47,7 @@ class="is-text-link" title="Move to the Prepared section" on:click={() => { - regionToUnskip = region.id + regionToSkip = region.id openModal('modal-confirm-unskip-region') }} > diff --git a/ohmg/frontend/svelte_components/src/lib/utils.js b/ohmg/frontend/svelte_components/src/lib/utils.js index a91d8b3c..4f6fa145 100644 --- a/ohmg/frontend/svelte_components/src/lib/utils.js +++ b/ohmg/frontend/svelte_components/src/lib/utils.js @@ -122,7 +122,6 @@ export function copyToClipboard(elementId) { const copyText = document.getElementById(elementId); copyText.select(); navigator.clipboard.writeText(copyText.value); - alert('Copied the text: ' + copyText.value); } export function makeSatelliteLayer(apiKey) { From b522b55cdbf4b52aceb0640d431b803967397741 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Tue, 23 Jun 2026 17:52:56 -0500 Subject: [PATCH 11/24] add jobs listing page and API endpoint --- ohmg/api/filters.py | 5 + ohmg/api/routes.py | 11 + ohmg/api/schemas.py | 49 +++ ohmg/frontend/static/css/fancy-icons.css | 6 + .../static/icons/noun-spanner-937070.svg | 22 ++ .../svelte_components/rollup.config.js | 1 + .../src/components/base/ModalInfo.svelte | 24 ++ .../src/components/tables/Jobs.svelte | 285 ++++++++++++++++++ ohmg/frontend/svelte_components/src/jobs.js | 6 + ohmg/frontend/templates/frontend/jobs.html | 17 ++ ohmg/frontend/urls.py | 2 + ohmg/frontend/views.py | 15 + 12 files changed, 443 insertions(+) create mode 100644 ohmg/frontend/static/icons/noun-spanner-937070.svg create mode 100644 ohmg/frontend/svelte_components/src/components/base/ModalInfo.svelte create mode 100644 ohmg/frontend/svelte_components/src/components/tables/Jobs.svelte create mode 100644 ohmg/frontend/svelte_components/src/jobs.js create mode 100644 ohmg/frontend/templates/frontend/jobs.html diff --git a/ohmg/api/filters.py b/ohmg/api/filters.py index 22a15c80..8042a7ba 100644 --- a/ohmg/api/filters.py +++ b/ohmg/api/filters.py @@ -17,6 +17,11 @@ class FilterSessionSchema(FilterSchema): type: Optional[str] +class FilterJobSchema(FilterSchema): + operation: Optional[str] = Field(q="operation") + stage: Optional[str] = Field(q="stage") + + class FilterDocumentSchema(FilterSchema): map: str = Field(q="map_id") prepared: Optional[bool] = Field(q="prepared") diff --git a/ohmg/api/routes.py b/ohmg/api/routes.py index db1b8d52..c40a43de 100644 --- a/ohmg/api/routes.py +++ b/ohmg/api/routes.py @@ -21,6 +21,7 @@ Region, ) from ohmg.georeference.models import ( + Job, SessionBase, SessionLock, ) @@ -29,6 +30,7 @@ from .filters import ( FilterAllDocumentsSchema, FilterDocumentSchema, + FilterJobSchema, FilterRegionSchema, FilterSessionSchema, ) @@ -39,6 +41,7 @@ ) from .schemas import ( DocumentSchema, + JobSchema, LayerSchema, LayerSetSchema, MapFullSchema, @@ -76,6 +79,14 @@ def authenticate(self, request, key): ) +@beta2.get("jobs/", response=List[JobSchema], url_name="job_list") +@paginate +def list_jobs(request, filters: FilterJobSchema = Query(...)): + queryset = Job.objects.all().order_by("-date_queued") + queryset = filters.filter(queryset) + return queryset + + @beta2.get("sessions/", response=List[SessionSchema], url_name="session_list") @paginate(SessionPagination) def list_sessions(request, filters: FilterSessionSchema = Query(...), date_range: str = ""): diff --git a/ohmg/api/schemas.py b/ohmg/api/schemas.py index 95d5855d..a574048e 100644 --- a/ohmg/api/schemas.py +++ b/ohmg/api/schemas.py @@ -29,6 +29,13 @@ logger = logging.getLogger(__name__) +def _datefield_to_timestamp(obj, field: str) -> float | None: + try: + return getattr(obj, field).timestamp() + except (AttributeError, ValueError): + return None + + class UserSchema(Schema): username: str profile_url: str @@ -886,6 +893,48 @@ def resolve_status(obj): return "split" +class JobTargetSchema(Schema): + name: str + url: str + + @staticmethod + def resolve_name(obj): + return str(obj) + + @staticmethod + def resolve_url(obj): + return f"/map/{obj.map.pk}" + + +class JobSchema(Schema): + id: int + operation: str + stage: str + message: Optional[str] + target: JobTargetSchema + date_created: Optional[float] + date_queued: Optional[float] + date_started: Optional[float] + date_ended: Optional[float] + run_duration: Optional[int] + + @staticmethod + def resolve_date_created(obj): + return _datefield_to_timestamp(obj, "date_created") + + @staticmethod + def resolve_date_queued(obj): + return _datefield_to_timestamp(obj, "date_queued") + + @staticmethod + def resolve_date_started(obj): + return _datefield_to_timestamp(obj, "date_started") + + @staticmethod + def resolve_date_ended(obj): + return _datefield_to_timestamp(obj, "date_ended") + + DocumentFullSchema.update_forward_refs() RegionFullSchema.update_forward_refs() MapListSchema2.update_forward_refs() diff --git a/ohmg/frontend/static/css/fancy-icons.css b/ohmg/frontend/static/css/fancy-icons.css index 15807af4..c6a9ee7b 100644 --- a/ohmg/frontend/static/css/fancy-icons.css +++ b/ohmg/frontend/static/css/fancy-icons.css @@ -14,6 +14,7 @@ --pushpin-svg: url(/static/icons/pushpin-60.svg); --envelope-svg: url(/static/icons/envelope-60.svg); --clipboard-svg: url(/static/icons/clipboard-60.svg); + --wrench-svg: url(/static/icons/noun-spanner-937070.svg); } i.fancy { @@ -120,3 +121,8 @@ i.fancy-xs { mask-image: var(--clipboard-svg); -webkit-mask-image: var(--clipboard-svg); } + +.i-wrench { + mask-image: var(--wrench-svg); + -webkit-mask-image: var(--wrench-svg); +} diff --git a/ohmg/frontend/static/icons/noun-spanner-937070.svg b/ohmg/frontend/static/icons/noun-spanner-937070.svg new file mode 100644 index 00000000..390e0748 --- /dev/null +++ b/ohmg/frontend/static/icons/noun-spanner-937070.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/ohmg/frontend/svelte_components/rollup.config.js b/ohmg/frontend/svelte_components/rollup.config.js index fadb672f..db64308b 100644 --- a/ohmg/frontend/svelte_components/rollup.config.js +++ b/ohmg/frontend/svelte_components/rollup.config.js @@ -73,6 +73,7 @@ export default (cliArgs) => { 'Map', 'Sessions', 'Maps', + 'Jobs', 'Profiles', 'LatestBlogPosts', 'MapBrowse', diff --git a/ohmg/frontend/svelte_components/src/components/base/ModalInfo.svelte b/ohmg/frontend/svelte_components/src/components/base/ModalInfo.svelte new file mode 100644 index 00000000..02ba1e0f --- /dev/null +++ b/ohmg/frontend/svelte_components/src/components/base/ModalInfo.svelte @@ -0,0 +1,24 @@ + + + + +

    Information here

    +
    + {#if buttonText} + + {/if} +
    diff --git a/ohmg/frontend/svelte_components/src/components/tables/Jobs.svelte b/ohmg/frontend/svelte_components/src/components/tables/Jobs.svelte new file mode 100644 index 00000000..1ae1a4d1 --- /dev/null +++ b/ohmg/frontend/svelte_components/src/components/tables/Jobs.svelte @@ -0,0 +1,285 @@ + + + +
    + {#if jobDetails} +
    id
    +
    {jobDetails.id}
    +
    operation
    +
    {jobDetails.operation}
    +
    stage
    +
    {jobDetails.stage}
    +
    message
    +
    {jobDetails.message}
    +
    target
    +
    {jobDetails.target.name}
    +
    date_created
    +
    {timestampToFullString(jobDetails.date_created)}
    +
    date_queued
    +
    {timestampToFullString(jobDetails.date_queued)}
    +
    date_started
    +
    {timestampToFullString(jobDetails.date_started)}
    +
    date_ended
    +
    {timestampToFullString(jobDetails.date_ended)}
    +
    run_duration (seconds)
    +
    {jobDetails.run_duration}
    + {/if} +
    + +
    +
    +
    +
    + + {#if allowRefresh} + + {/if} +
    +
    + {#if paginate} +
    + +
    + {/if} +
    +
    + {#if showFilters} +
    +
    + + +
    +
    + +
    +
    + {/if} +
    + {#if items.length > 0} +
    + + + + + + + + + + + + + + {#each items as s} + + + + + + + + + + {/each} + +
    IdTargetOperationStageQueuedStartedDuration
    {s.target.name}{s.operation}{s.stage}{timestampToShortString(s.date_queued)}{timestampToShortString(s.date_started)}{secondsToHHMMSS(s.run_duration)}
    +
    + {:else} +
    +
    + {loading ? 'loading...' : 'no results'} +
    +
    + {/if} +
    +
    + + diff --git a/ohmg/frontend/svelte_components/src/jobs.js b/ohmg/frontend/svelte_components/src/jobs.js new file mode 100644 index 00000000..f9a906e2 --- /dev/null +++ b/ohmg/frontend/svelte_components/src/jobs.js @@ -0,0 +1,6 @@ +import Jobs from './components/tables/Jobs.svelte'; + +export default new Jobs({ + target: document.getElementById('jobs-target'), + props: JSON.parse(document.getElementById('jobs-props').textContent), +}); diff --git a/ohmg/frontend/templates/frontend/jobs.html b/ohmg/frontend/templates/frontend/jobs.html new file mode 100644 index 00000000..d5675372 --- /dev/null +++ b/ohmg/frontend/templates/frontend/jobs.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} + +{% block title %}Jobs | {{ block.super }}{% endblock %} + +{% block main_content %} + +{% include '_titlebar.html' with title="Jobs" subtitle="see also: sessions" lead_icon="wrench" %} + +
    +
    + {{ JOBS_PROPS|json_script:'jobs-props'}} + {% include '_svelte_component.html' with component_name='Jobs' %} +
    +
    +{% endblock %} diff --git a/ohmg/frontend/urls.py b/ohmg/frontend/urls.py index ff0b7fa7..19baa0b4 100644 --- a/ohmg/frontend/urls.py +++ b/ohmg/frontend/urls.py @@ -11,6 +11,7 @@ ActivityView, Browse, HomePage, + JobsView, PageView, ) @@ -20,6 +21,7 @@ ## app urls paths path("", HomePage.as_view(), name="home"), path("activity/", ActivityView.as_view(), name="activity"), + path("jobs/", JobsView.as_view(), name="jobs"), path("search/", Browse.as_view(), name="search"), path("/", PageView.as_view(), name="page-view"), ## conventional url paths diff --git a/ohmg/frontend/views.py b/ohmg/frontend/views.py index f210c4aa..3d7f8e8a 100644 --- a/ohmg/frontend/views.py +++ b/ohmg/frontend/views.py @@ -90,6 +90,21 @@ def get(self, request): return render(request, "frontend/activity.html", context=context_dict) +class JobsView(View): + def get(self, request): + ohmg_context = generate_ohmg_context(request) + context_dict = { + "CONTEXT": ohmg_context, + "JOBS_PROPS": { + "CONTEXT": ohmg_context, + "showThumbs": True, + "limit": "25", + }, + } + + return render(request, "frontend/jobs.html", context=context_dict) + + class PageView(View): def get(self, request, page): m_date = page.date_modified.strftime("%B %d, %Y") From 679fcf7946dd952a54463389a277220e713dd4d9 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Tue, 23 Jun 2026 18:16:11 -0500 Subject: [PATCH 12/24] get mosaic dates from linked jobs --- ohmg/api/schemas.py | 26 ++---- ohmg/conf/settings.py | 2 +- ohmg/core/admin.py | 6 +- ...e_layerset_mosaic_geotiff_date_and_more.py | 21 +++++ ohmg/core/models/layerset.py | 26 +++++- .../components/map/MosaicDerivatives.svelte | 92 ++++++++++++------- .../components/map/MultimaskSection.svelte | 11 ++- .../src/components/tables/Jobs.svelte | 3 - ohmg/georeference/mosaicker.py | 2 - ohmg/georeference/tasks.py | 3 + ohmg/georeference/urls.py | 7 +- ohmg/georeference/views.py | 20 +++- 12 files changed, 140 insertions(+), 79 deletions(-) create mode 100644 ohmg/core/migrations/0015_remove_layerset_mosaic_geotiff_date_and_more.py diff --git a/ohmg/api/schemas.py b/ohmg/api/schemas.py index a574048e..8a10676a 100644 --- a/ohmg/api/schemas.py +++ b/ohmg/api/schemas.py @@ -494,10 +494,10 @@ class LayerSetSchema(Schema): extent: Optional[tuple] multimask_extent: Optional[tuple] mosaic_cog_url: Optional[str] - mosaic_geotiff_date: Optional[str] xyz_tiles_url: Optional[str] - xyz_tiles_date: Optional[str] multimask_date: Optional[float] + latest_cog_job: Optional["JobSchema"] + latest_xyz_job: Optional["JobSchema"] @staticmethod def resolve_id(obj): @@ -516,25 +516,16 @@ def resolve_name(obj): return str(obj.category) @staticmethod - def resolve_mosaic_geotiff_date(obj): - try: - return obj.mosaic_geotiff_date.timestamp() - except AttributeError: - return None + def resolve_multimask_date(obj): + return _datefield_to_timestamp(obj, "multimask_date") @staticmethod - def resolve_xyz_tiles_date(obj): - try: - return obj.xyz_tiles_date.timestamp() - except AttributeError: - return None + def resolve_latest_cog_job(obj): + return obj.get_latest_cog_job() @staticmethod - def resolve_multimask_date(obj): - try: - return obj.multimask_date.timestamp() - except AttributeError: - return None + def resolve_latest_xyz_job(obj): + return obj.get_latest_xyz_job() class LayerSetDisplaySchema(Schema): @@ -938,3 +929,4 @@ def resolve_date_ended(obj): DocumentFullSchema.update_forward_refs() RegionFullSchema.update_forward_refs() MapListSchema2.update_forward_refs() +LayerSetSchema.update_forward_refs() diff --git a/ohmg/conf/settings.py b/ohmg/conf/settings.py index 9855e057..6bae135c 100644 --- a/ohmg/conf/settings.py +++ b/ohmg/conf/settings.py @@ -342,7 +342,7 @@ }, "run_queued_mosaic_jobs": { "task": "ohmg.georeference.tasks.run_queued_mosaic_jobs", - "schedule": 60.0, + "schedule": 15.0, }, } diff --git a/ohmg/core/admin.py b/ohmg/core/admin.py index a99cd274..84b61476 100644 --- a/ohmg/core/admin.py +++ b/ohmg/core/admin.py @@ -64,8 +64,7 @@ class LayerSetAdmin(admin.ModelAdmin): list_display = ( "map", "category", - "mosaic_geotiff_date", - "xyz_tiles_date", + "multimask_date", ) raw_id_fields = ("map",) readonly_fields = ( @@ -73,8 +72,7 @@ class LayerSetAdmin(admin.ModelAdmin): "extent", "multimask_extent", "xyz_tiles_url", - "mosaic_geotiff_date", - "xyz_tiles_date", + "multimask_date", ) search_fields = ("map__title",) list_filter = ("category",) diff --git a/ohmg/core/migrations/0015_remove_layerset_mosaic_geotiff_date_and_more.py b/ohmg/core/migrations/0015_remove_layerset_mosaic_geotiff_date_and_more.py new file mode 100644 index 00000000..dced664b --- /dev/null +++ b/ohmg/core/migrations/0015_remove_layerset_mosaic_geotiff_date_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.27 on 2026-06-23 18:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0014_alter_permissions_options_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='layerset', + name='mosaic_geotiff_date', + ), + migrations.RemoveField( + model_name='layerset', + name='xyz_tiles_date', + ), + ] diff --git a/ohmg/core/models/layerset.py b/ohmg/core/models/layerset.py index de7f80f4..38e43022 100644 --- a/ohmg/core/models/layerset.py +++ b/ohmg/core/models/layerset.py @@ -53,7 +53,6 @@ class Meta: blank=True, max_length=255, ) - mosaic_geotiff_date = models.DateTimeField(blank=True, null=True) mosaic_json = models.FileField( upload_to="mosaics", null=True, @@ -71,7 +70,6 @@ class Meta: blank=True, null=True, ) - xyz_tiles_date = models.DateTimeField(blank=True, null=True) tilejson = models.JSONField(null=True, blank=True) multimask_date = models.DateTimeField(blank=True, null=True) @@ -90,6 +88,28 @@ def layer_display_list(self): def get_layers(self) -> Iterable["Layer"]: return self.layer_set.all() + def get_latest_cog_job(self): + from ohmg.georeference.models import Job + + ct = ContentType.objects.get_for_model(self) + job = ( + Job.objects.filter(operation="layerset_to_cog", target_type=ct, target_id=self.pk) + .order_by("-date_queued") + .first() + ) + return job + + def get_latest_xyz_job(self): + from ohmg.georeference.models import Job + + ct = ContentType.objects.get_for_model(self) + job = ( + Job.objects.filter(operation="layerset_to_xyz", target_type=ct, target_id=self.pk) + .order_by("-date_queued") + .first() + ) + return job + @property def xyz_tiles_url(self): if self.xyz_tiles_prefix: @@ -108,7 +128,7 @@ def xyz_tiles_download_url(self): base_url = f"{settings.AWS_S3_ENDPOINT_URL}/{settings.AWS_STORAGE_BUCKET_NAME}" else: base_url = f"{settings.SITEURL.rstrip('/')}{settings.MEDIA_URL}" - return f"{base_url.rstrip('/')}/{self.xyz_tiles_prefix}/{self.mosaic_xyz_tiles_date}" + return f"{base_url.rstrip('/')}/{self.xyz_tiles_prefix}/" else: return None diff --git a/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte b/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte index 17b56e2f..de2859bc 100644 --- a/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte +++ b/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte @@ -1,21 +1,16 @@ + +

    Job submitted. You can track its completion on the jobs page.

    +
    {submitQueueRequest('queue-cog-creation')}} > @@ -90,27 +113,28 @@ {submitQueueRequest('queue-tileset-creation')}} > -

    Submit tileset generation to queue?

    +

    Submit XYZ tileset generation to queue?

    - Once layers have been trimmed in the MultiMask, a background process can be run - to combine them into a single mosaic file, which serves as a basis for downloads and web services. - If you see n/a below, the mosaic has not yet been created. You can still access - individual layers through the Georeferenced section, or view the mosaic in Allmaps - (powered by IIIF). + Once layers have been trimmed in the MultiMask they can be combined into a single + layer, which takes the form of a "cloud-optimized GeoTIFF" (COG) and/or static XYZ tileset. + These formats form the basis for many other data access methods as displayed below. +

    +

    If the MultiMask is updated after a mosaic has been generated, dates will be shown here in + red until the mosaic artifacts are re-generated.

    {#each layersets as ls} {#if ls.layers.length >= 1}

    - {`${ls.name} (${ls.layers.length} layer${ls.layers.length > 1 ? 's' : ''})`} + {`${ls.name} (${ls.layers.length}/${ls.layers_masked_ct} layers masked)`} {#if ls.multimask_date} - masks last updated: {ls.localeDateMasks} + masks last updated: {ls.masksDateDisplay} {/if}

    @@ -118,8 +142,8 @@ {#if ls.mosaic_cog_url} {#if ls.xyz_tiles_archive} {#if ls.tileJsonUrl} @@ -156,8 +180,8 @@ {#if ls.mosaic_cog_url} @@ -166,8 +190,8 @@ {#if ls.xyz_tiles_url} @@ -187,8 +211,8 @@ {#if ls.mosaic_cog_url} diff --git a/ohmg/frontend/svelte_components/src/components/map/MultimaskSection.svelte b/ohmg/frontend/svelte_components/src/components/map/MultimaskSection.svelte index dcc69206..a64e19e7 100644 --- a/ohmg/frontend/svelte_components/src/components/map/MultimaskSection.svelte +++ b/ohmg/frontend/svelte_components/src/components/map/MultimaskSection.svelte @@ -1,8 +1,9 @@ - +

    Masks saved successfully.

    -
    - + +

    Error! MultiMask not saved. The following layers have errors:

    {@html errMsg}

    You must fix these errors before you can save your work. Keep in mind it is sometimes easier to remove and recreate a mask than track down and fix specific issues.

    -
    +

    Select which multimask to work on: diff --git a/ohmg/frontend/svelte_components/src/components/tables/Jobs.svelte b/ohmg/frontend/svelte_components/src/components/tables/Jobs.svelte index 1ae1a4d1..53296168 100644 --- a/ohmg/frontend/svelte_components/src/components/tables/Jobs.svelte +++ b/ohmg/frontend/svelte_components/src/components/tables/Jobs.svelte @@ -238,9 +238,6 @@ position: sticky; top: 0; } - th > * { - display: flex; - } td { white-space: nowrap; padding: 2px 0.5em 2px 0; diff --git a/ohmg/georeference/mosaicker.py b/ohmg/georeference/mosaicker.py index 82a720ee..37bf9244 100644 --- a/ohmg/georeference/mosaicker.py +++ b/ohmg/georeference/mosaicker.py @@ -135,7 +135,6 @@ def generate_cog(self, layerset: LayerSet): if existing_file_name and storage.exists(name=existing_file_name): storage.delete(name=existing_file_name) - layerset.mosaic_geotiff_date = datetime.now() layerset.save(set_tilejson=True) def generate_tileset( @@ -164,7 +163,6 @@ def generate_tileset( existing_tileset_prefix = layerset.xyz_tiles_prefix layerset.xyz_tiles_prefix = prefix - layerset.xyz_tiles_date = datetime.now() layerset.save() ## clean up existing tileset diff --git a/ohmg/georeference/tasks.py b/ohmg/georeference/tasks.py index 44f61333..3403b495 100644 --- a/ohmg/georeference/tasks.py +++ b/ohmg/georeference/tasks.py @@ -128,8 +128,11 @@ def run_queued_mosaic_jobs(): ## if there are already max mosaic jobs running, don't start another one running_jobs_ct = mosaic_jobs.filter(stage="running").count() spots_left = max_jobs_ct - running_jobs_ct + started = [] if spots_left: queued_jobs = mosaic_jobs.filter(stage="queued").order_by("date_queued") for job in queued_jobs[:spots_left]: logger.info(f"starting queued job {job.pk}: {job.operation}, {job.target}") job.run() + started.append(job.pk) + return {"jobs": started} diff --git a/ohmg/georeference/urls.py b/ohmg/georeference/urls.py index f4bc8858..730409cc 100644 --- a/ohmg/georeference/urls.py +++ b/ohmg/georeference/urls.py @@ -1,10 +1,6 @@ from django.urls import path -from .views import ( - GeoreferenceView, - SessionView, - SplitView, -) +from .views import GeoreferenceView, JobView, SessionView, SplitView urlpatterns = [ path("split/", SplitView.as_view(), name="base_split_view"), @@ -15,4 +11,5 @@ name="georeference_view", ), path("session//", SessionView.as_view(), name="session_view"), + path("job//", JobView.as_view(), name="job_view"), ] diff --git a/ohmg/georeference/views.py b/ohmg/georeference/views.py index 49f88c2e..f4ddd1dd 100644 --- a/ohmg/georeference/views.py +++ b/ohmg/georeference/views.py @@ -29,11 +29,7 @@ from ohmg.core.utils.performance import time_this_function from .georeferencer import Georeferencer -from .models import ( - GeorefSession, - PrepSession, - SessionBase, -) +from .models import GeorefSession, Job, PrepSession, SessionBase from .splitter import Splitter from .tasks import ( bulk_run_preparation_sessions, @@ -361,3 +357,17 @@ def post(self, request, sessionid): except Exception as e: return JsonResponseFail(e) return JsonResponseSuccess() + + +class JobView(View): + @method_decorator(validate_post_request(operations=["queue"])) + def post(self, request, jobid): + print(jobid) + job = get_object_or_404(Job, pk=jobid) + print(job) + body = json.loads(request.body) + operation = body.get("operation") + + if operation == "queue": + job.enqueue() + return JsonResponseSuccess() From daa0f28e2ef28e80d1417190eaa6a194c22518b5 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Wed, 24 Jun 2026 13:01:21 -0500 Subject: [PATCH 13/24] add tileset archive generation and download --- ohmg/core/utils/s3.py | 27 +++++++- .../components/map/MosaicDerivatives.svelte | 43 ++++++++---- .../components/shared/DerivativeItem.svelte | 9 +-- ohmg/georeference/mosaicker.py | 4 +- ohmg/georeference/utils.py | 65 ++++++++++++------- 5 files changed, 105 insertions(+), 43 deletions(-) diff --git a/ohmg/core/utils/s3.py b/ohmg/core/utils/s3.py index 743a6b59..515c7ae3 100644 --- a/ohmg/core/utils/s3.py +++ b/ohmg/core/utils/s3.py @@ -1,10 +1,12 @@ +from pathlib import Path + from django.conf import settings + def get_boto3_s3_client(): import boto3 if settings.AWS_ACCESS_KEY_ID: - return boto3.client( "s3", region_name=settings.AWS_S3_REGION_NAME, @@ -14,3 +16,26 @@ def get_boto3_s3_client(): ) else: return None + + +def upload_file_to_bucket(local_path, bucket_path, client=None): + if not client: + client = get_boto3_s3_client() + client.upload_file(local_path, settings.AWS_STORAGE_BUCKET_NAME, bucket_path) + + +# def upload_directory_to_bucket(local_path, bucket_path, client=None): +# if not client: +# client = get_boto3_s3_client() + + +def upload_directory_to_bucket(local_path: Path, bucket_path: str, client=None): + if not client: + client = get_boto3_s3_client() + for file_path in local_path.rglob("*"): + if file_path.is_file(): + # Get relative path, format for S3 (replace backslashes) + s3_key = str(file_path.relative_to(local_path)).replace("\\", "/") + if bucket_path: + s3_key = f"{bucket_path}/{s3_key}" + upload_file_to_bucket(file_path, s3_key, client=client) diff --git a/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte b/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte index de2859bc..0de9ed77 100644 --- a/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte +++ b/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte @@ -46,8 +46,9 @@ i.allmapsUrl = `https://viewer.allmaps.org/?url=${encodeURIComponent(i.iiifAnnoUrl)}` i.ohmUrl = `${CONTEXT.site_url}map/${mapId}/${i.id}/ohm` i.tileJsonUrl = `${CONTEXT.site_url}map/${mapId}/${i.id}/tilejson` + i.dynamicXyzUrl = i.mosaic_cog_url ? `${CONTEXT.titiler_host}/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png?${encodeURIComponent(i.mosaic_cog_url)}` : null; i.masksDateDisplay = i.multimask_date ? new Date(i.multimask_date*1000).toLocaleString() : null; - + i.xyz_tiles_archive = i.xyz_tiles_url ? `${i.xyz_tiles_url}/archive.tar.gz` : null; i.cogStale = false; i.cogDateDisplay = "---" if (i.latest_cog_job) { @@ -76,6 +77,7 @@ i.xyzDateDisplay = "not generated" } console.log(i) + console.log(CONTEXT) return i }); }); @@ -143,7 +145,8 @@ {#if ls.mosaic_cog_url} {ls.mosaic_cog_url} + {:else} + not generated {/if} {#if ls.xyz_tiles_archive} {ls.xyz_tiles_archive} + {:else} + not generated {/if} {#if ls.tileJsonUrl} + {:else} + requires COG {/if} - {#if ls.mosaic_cog_url} - + {#if ls.dynamicXyzUrl} + + {:else} + requires COG {/if} {#if ls.xyz_tiles_url} + {:else} + requires XYZ tileset {/if} {#if ls.mosaic_cog_url} {ls.ohmUrl} + {:else} + requires COG {/if} @@ -286,4 +297,10 @@ button > span { margin-left: 5px; } + .na-message { + font-size: .8em; + } + .na-message::before { + content: "-- " + } \ No newline at end of file diff --git a/ohmg/frontend/svelte_components/src/components/shared/DerivativeItem.svelte b/ohmg/frontend/svelte_components/src/components/shared/DerivativeItem.svelte index cf58a5aa..499369bf 100644 --- a/ohmg/frontend/svelte_components/src/components/shared/DerivativeItem.svelte +++ b/ohmg/frontend/svelte_components/src/components/shared/DerivativeItem.svelte @@ -2,10 +2,9 @@ export let title; export let dateTimestamp = null; export let dateString = null; - export let dateStale = false; - export let naMessage = "not generated"; + export let isStale = false; - let dateClass = dateStale ? "timestamp stale" : "timestamp"; + let dateClass = isStale ? "timestamp stale" : "timestamp"; @@ -18,9 +17,7 @@ {/if}

    - - {naMessage} - +
    \ No newline at end of file diff --git a/ohmg/frontend/svelte_components/src/components/shared/DerivativeDD.svelte b/ohmg/frontend/svelte_components/src/components/shared/DerivativeDD.svelte new file mode 100644 index 00000000..cf904317 --- /dev/null +++ b/ohmg/frontend/svelte_components/src/components/shared/DerivativeDD.svelte @@ -0,0 +1,51 @@ + + +
    + {#if linkUrl} + + {#if linkType == "copytext"} + + {:else if linkType == "download"} + + {useLabel} + + {:else if linkType == "external"} + + {useLabel} + + {/if} + + {:else} + {naMessage} + {/if} +
    + + \ No newline at end of file diff --git a/ohmg/frontend/svelte_components/src/components/shared/DerivativeItem.svelte b/ohmg/frontend/svelte_components/src/components/shared/DerivativeItem.svelte deleted file mode 100644 index 499369bf..00000000 --- a/ohmg/frontend/svelte_components/src/components/shared/DerivativeItem.svelte +++ /dev/null @@ -1,58 +0,0 @@ - - - -
    - {title} - {#if dateTimestamp} - {new Date(dateTimestamp * 1000).toLocaleString()} - {:else if dateString} - {dateString} - {/if} -
    -
    - -
    - - \ No newline at end of file diff --git a/ohmg/frontend/svelte_components/src/components/shared/DerivativeSubheader.svelte b/ohmg/frontend/svelte_components/src/components/shared/DerivativeSubheader.svelte deleted file mode 100644 index 0f9b1556..00000000 --- a/ohmg/frontend/svelte_components/src/components/shared/DerivativeSubheader.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - -
    {title}
    - - \ No newline at end of file diff --git a/ohmg/frontend/svelte_components/src/components/shared/buttons/CopyableText.svelte b/ohmg/frontend/svelte_components/src/components/shared/buttons/CopyableText.svelte index 6bc21287..389094f5 100644 --- a/ohmg/frontend/svelte_components/src/components/shared/buttons/CopyableText.svelte +++ b/ohmg/frontend/svelte_components/src/components/shared/buttons/CopyableText.svelte @@ -32,13 +32,11 @@ color: var(--theme-highlight-color); } button.text-as-label { - font-family: mono; - font-size: 0.9em; text-align: start; } .report-copied { background:rgb(245,245,245); padding:5px; - font-family: mono; + font-family: 'Fira Code', mono; } \ No newline at end of file From e438c817822a31302b34c93d1712d8dc580e45a0 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Mon, 13 Jul 2026 13:12:15 -0500 Subject: [PATCH 21/24] fix queue btn presence, add wms link --- .../components/map/MosaicDerivatives.svelte | 63 ++++++++++++------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte b/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte index 77240d10..a4c73832 100644 --- a/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte +++ b/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte @@ -1,7 +1,4 @@ - -

    Record

    Title
    diff --git a/ohmg/frontend/svelte_components/src/components/shared/modals/ResourceDownloadSectionModal.svelte b/ohmg/frontend/svelte_components/src/components/shared/modals/ResourceDownloadSectionModal.svelte deleted file mode 100644 index b14b5326..00000000 --- a/ohmg/frontend/svelte_components/src/components/shared/modals/ResourceDownloadSectionModal.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - - -

    Download & Web Services

    -

    How to use XYZ tiles in desktop software

    -
      -
    • - QGIS -
    • -
    • - ArcGIS -
    • -
    -

    How to use XYZ tiles on the web

    -
      -
    • Leaflet
    • -
    • OpenLayers
    • -
    • - Mapbox/MapLibre GL JS -
    • -
    • - ArcGIS Online -
    • -
    • - OpenHistoricalMap (iD Editor) -
    • -
    -
    From 5d451a983cfa4f385b76ced5b70c986ff11d559e Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Mon, 13 Jul 2026 13:13:19 -0500 Subject: [PATCH 23/24] add docs for mosaics and endpoints --- docs/guides/generating-mosaics.md | 28 +++++++++++++++++ docs/guides/services.md | 48 ++++++++++++++++++++++++++++++ docs/guides/trimming.md | 6 +++- docs/images/mosaic-cog-fresh.png | Bin 0 -> 18858 bytes docs/images/mosaic-cog-stale.png | Bin 0 -> 20403 bytes zensical.toml | 4 +++ 6 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 docs/guides/generating-mosaics.md create mode 100644 docs/guides/services.md create mode 100644 docs/images/mosaic-cog-fresh.png create mode 100644 docs/images/mosaic-cog-stale.png diff --git a/docs/guides/generating-mosaics.md b/docs/guides/generating-mosaics.md new file mode 100644 index 00000000..448b73f4 --- /dev/null +++ b/docs/guides/generating-mosaics.md @@ -0,0 +1,28 @@ +# Generating mosaics + +Once layers are grouped into layersets, and each layerset is trimmed in the [multimask](./trimming.md) interface, the final step is to collapse the content into a single mosaic per layerset, like finally gluing down all the individual clippings in a collage. You'll find these within within a map's **Mosaic** → **Derivatives** (see [New Iberia, 1885](https://oldinsurancemaps.net/map/sanborn03375_001#mosaic)). + +Currently, OIM supports the creation of two different derivatives per layerset: + +- **Cloud Optimized GeoTIFFs (COG)** + - This is the primary output, and when present, is used within OIM in certain contexts +- **Static XYZ Tileset** + - This is a rendering of the latest COG mosaic to actual PNG tiles in a {z}/{y}/{x}.png folder structure. + +!!! note + + You may be wondering what happens to Key Maps and other categories of maps, i.e. other layersets. Though these are handled on the backend exactly like the Main Content layerset, they are only shown in the derivatives list if they have more than one layer. + +## Keeping derivatives up-to-date + +The creation of a single geotiff (or static XYZ tileset) is effectively a snapshot of the work that has been performed on a map's layers and masks up to that point. Timestamps are displayed showing when masks were last updated, and when derivatives were last generated. + +![up-to-date cog derivative with timestamps](../images/mosaic-cog-fresh.png) + +However, this means that if the ground control points are subsequently updated for a layer, or the masks are adjusted, the derivative artifact will be out-of-date. In this case, the interface will show timestamps of out-of-date artifacts in red and a button will be available allowing you to queue this artifact to be rebuilt. + +![out-of-date cog derivative with red timestamp](../images/mosaic-cog-stale.png) + +Because the process to create a single geotiff (or xyz tileset) from dozens or hundreds of layers can take several hours, we use a queueing system to ensure the server is never overloaded. You can see all mosaicking processes (queued, running, or completed) in [Jobs](https://oldinsurancemaps.net/jobs). + +In the current setup, only two jobs are allowed to run at any time, and queued mosaic operations are processed with a first in, first out policy. diff --git a/docs/guides/services.md b/docs/guides/services.md new file mode 100644 index 00000000..6c8c98b3 --- /dev/null +++ b/docs/guides/services.md @@ -0,0 +1,48 @@ + +# Downloads & web services + +Historical map content can be accessed in a number of different ways and used in many different applications. Throughout the platform you will find places to download or link to: + +1. Individual layers that have been georeferenced + - Find these within a map's **Georeferenced** section: https://oldinsurancemaps.net/map/sanborn03375_001#georeferenced +2. Unified mosaics made from multiple layers + - Find these within a map's **Mosaic** → **Derivatives** section: https://oldinsurancemaps.net/map/sanborn03375_001#mosaic + +!!! warning + + When downloading or otherwise accessing maps, please keep in mind that **OldInsuranceMaps.net provides no guarantee as to the accuracy of georeferenced content.** + +!!! important + + Over time, someone may come along and improve the georeferencing on a layer, or update multimask boundaries and regenerated a mosaic. When this happens the original file is deleted and a new one is created. Because many of the endpoints described below contain the actual filename, **tile service links in 3rd-party integrations may need to be updated after the source layer or mosaic has been updated**. + +## Downloadable geotiffs (COGs) + +OIM relies heavily on [cloud optimized geotiffs](https://cogeo.org/), or "COGs", and you can download these files at any time and easily use them directly in software like QGIS or ArcGIS Desktop. All COGs use JPEG compression in order to keep file sizes manageable. + +## Tile services + +OIM provides service endpoints that can be used in many different applications. The particular service endpoint you use will largely depend on where you are trying to use it, as not all applications support all types of endpoints. + +Generally speaking, XYZ tiles are the most widely supported, TileJSON is a superset spec that wraps an XYZ tile endpoint with some extra information, and WMS is an older standard still in use in many applications. + +OIM uses TiTiler to dynamically produce tiled services from the same source COG files that you can download as described above. + +### XYZ tiles + +XYZ tiles are a very widely supported standard supported by most geospatial software and mapping applications. Here is more info for how to use these URLs in desktop software like [QGIS](https://docs.qgis.org/3.22/en/docs/user_manual/managing_data_source/opening_data.html#using-xyz-tile-services) and [ArcGIS](https://esribelux.com/2021/04/16/xyz-tile-layers-in-arcgis-platform/), as well as web mapping applications like [Leaflet](https://leafletjs.com/reference.html#tilelayer), [OpenLayers](https://openlayers.org/en/latest/examples/xyz.html), [Mapbox/Maplibre GL JS](https://maplibre.org/maplibre-gl-js-docs/example/map-tiles/), [ArcGIS Online](https://doc.arcgis.com/en/arcgis-online/create-maps/add-layers-from-url.htm#ESRI_SECTION1_13A68145A4F94A338AB2002AD5F9844D), and [OpenHistoricalMap (iD Editor)](https://learnosm.org/en/beginner/id-editor/#configuring-the-background-layer). + +!!! note + + Some mosaics will also have an **Static XYZ Tileset** available. This is a static, pre-rendered tileset that you can download, unpack, and host on your own infrastructure. It is different from the XYZ tiles endpoints in that it does not rely on any dynamic tiling service, like TiTiler. + +### TileJSON + +[TileJSON](https://github.com/mapbox/tilejson-spec) is a useful spec that wraps an XYZ endpoint with a few other pieces of information. + +Currently, this is the most reliable OIM-provided endpoint to use because while the underlying XYZ url which may change (as described above), the TileJSON url itself will not change. However, TileJSON is not as widely supported as XYZ tiles. + + +## IIIF Georeference Extension + +For each layer and mosaic you can also access a collection of the georeferencing information formatted as the [IIIF Georeference Extension](https://iiif.io/api/extension/georef/). This annotation will includes the IIIF URLs from the source resources in the Library of Congress and when you open it in Allmaps Viewer, the tiles will be coming directly from LOC and being warped in the browser according to control points and masks created in OIM. Learn more in this slideshow: [creating IIIF georeference annotations](https://docs.google.com/presentation/d/18FRpq1Lcz3T7WincZNRvTjdL87ULy1bU68BCxr425dk/edit?usp=sharing). \ No newline at end of file diff --git a/docs/guides/trimming.md b/docs/guides/trimming.md index 8d9e9643..67620b45 100644 --- a/docs/guides/trimming.md +++ b/docs/guides/trimming.md @@ -2,4 +2,8 @@ Overlapping edges of layers cause messy mosaics, so a mask must be added to each layer to remove its margins. These masks should all be contiguous (sharing adjacent borders and vertices) so it is best to handle them at the map-level, not by masking each layer individually. This is the idea behind a **MultiMask**, which is a unified collection of masks for all layers within a map. -![The multitrim interface.](../images/multitrim.gif) \ No newline at end of file +![The multitrim interface.](../images/multitrim.gif) + +## Masking across multiple volumes + +In large cities it is common to have multiple volumes for a single atlas edition, each volume only covering a subset of the entire city. In the OIM data model, these volumes are treated as separate entities, so while the pages within each one can be carefully masked to form a mosaic, there is no affordance for combining those mosaics across all volumes within an edition. This is something we hope to support in the future. \ No newline at end of file diff --git a/docs/images/mosaic-cog-fresh.png b/docs/images/mosaic-cog-fresh.png new file mode 100644 index 0000000000000000000000000000000000000000..fe4842bb9e594a2a64ba05bb3b0f9e706b9bc519 GIT binary patch literal 18858 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 literal 0 HcmV?d00001 diff --git a/zensical.toml b/zensical.toml index 1371616e..74523066 100644 --- a/zensical.toml +++ b/zensical.toml @@ -60,6 +60,10 @@ nav = [ "guides/preparation.md", "guides/georeferencing.md", "guides/trimming.md", + "guides/generating-mosaics.md", + ]}, + { "Using georeferenced maps" = [ + "guides/services.md", ]}, { "Walkthroughs" = [ "walkthroughs/new-iberia-la-1885.md" From 94f8ef4d0ff4f430c6e388a173065e295f87f446 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Mon, 13 Jul 2026 13:17:21 -0500 Subject: [PATCH 24/24] better handle urls that rely on cog --- .../src/components/map/MosaicDerivatives.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte b/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte index a4c73832..fe4e8c1b 100644 --- a/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte +++ b/ohmg/frontend/svelte_components/src/components/map/MosaicDerivatives.svelte @@ -42,8 +42,8 @@ layersets = orderedLayersets.map(i => { i.iiifAnnoUrl = `${CONTEXT.site_url}iiif/mosaic/${mapId}/${i.id}/?trim=true` i.allmapsUrl = `https://viewer.allmaps.org/?url=${encodeURIComponent(i.iiifAnnoUrl)}` - i.ohmUrl = `${CONTEXT.site_url}map/${mapId}/${i.id}/ohm` - i.tileJsonUrl = `${CONTEXT.site_url}map/${mapId}/${i.id}/tilejson` + i.ohmUrl = i.mosaic_cog_url ? `${CONTEXT.site_url}map/${mapId}/${i.id}/ohm` : null; + i.tileJsonUrl = i.mosaic_cog_url ? `${CONTEXT.site_url}map/${mapId}/${i.id}/tilejson` : null; i.dynamicXyzUrl = i.mosaic_cog_url ? `${CONTEXT.titiler_host}/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png?${encodeURIComponent(i.mosaic_cog_url)}` : null; i.wmsUrl = i.mosaic_cog_url ? `${CONTEXT.titiler_preview_host}/cog/wms/?LAYERS=${encodeURIComponent(i.mosaic_cog_url)}&VERSION=1.1.1` : null; i.masksDateDisplay = i.multimask_date ? new Date(i.multimask_date*1000).toLocaleString() : null;