diff --git a/src/c3nav/mapdata/management/commands/importhub.py b/src/c3nav/mapdata/management/commands/importhub.py index 77b1a651..79f49429 100644 --- a/src/c3nav/mapdata/management/commands/importhub.py +++ b/src/c3nav/mapdata/management/commands/importhub.py @@ -5,8 +5,13 @@ from django.conf import settings from django.core.management.base import BaseCommand from pydantic import Field, BaseModel from pydantic import PositiveInt +from shapely import Point +from shapely.geometry import shape from c3nav.api.utils import NonEmptyStr +from c3nav.mapdata.models import Area, Space, LocationGroup, LocationSlug, MapUpdate +from c3nav.mapdata.models.geometry.space import POI +from c3nav.mapdata.utils.cache.changes import changed_geometries class HubImportItem(BaseModel): @@ -22,8 +27,8 @@ class HubImportItem(BaseModel): public_url: NonEmptyStr = Field(pattern=r'^https://') parent_id: UUID | None children: list[NonEmptyStr] | None - floor: PositiveInt - location: tuple[float, float] + floor: PositiveInt | None + location: tuple[float, float] | None polygons: tuple[list[tuple[float, float]]] | None @@ -31,131 +36,174 @@ class Command(BaseCommand): help = 'import from hub' def handle(self, *args, **options): - r = requests.get(settings.HUB_API_BASE+"/integration/c3nav", + r = requests.get(settings.HUB_API_BASE+"/integration/c3nav?all=1", headers={"Authorization": "Token "+settings.HUB_API_SECRET}) r.raise_for_status() - from pprint import pprint - data = [ - { - "type": "assembly", - "id": "037c0b9c-6283-42e4-9c43-24e072e8718e", - "slug": "fnord", - "name": "Fnord Example Habitat", - "is_official": False, - "description": { - "de": "Beispiel Habitat", - "en": "Example Cluster" - }, - "public_url": "https://hub.example.net/assembly/fnord/", - "parent_id": None, - "children": ["child"], - "floor": 2, - "location": [ - 13.301537039641516, - 53.03217295491487 - ], - "polygons": [ - [ - [ - 13.307995798949293, - 53.03178583543769 - ], - [ - 13.30780267990096, - 53.030276036273506 - ], - [ - 13.310034277800554, - 53.03009537300366 - ], - [ - 13.310205939178047, - 53.0315793702961 - ], - [ - 13.307995798949293, - 53.03178583543769 - ] - ] - ] - }, - { - "type": "assembly", - "id": "085657cc-9b46-4a71-853c-70e10a371e57", - "slug": "kika", - "name": "Kinderkanal", - "is_official": True, - "description": { - "de": "Danke für deinen Betrag.", - "en": "Thanks for your support." - }, - "public_url": "https://hub.example.net/assembly/kika/", - "parent_id": None, - "children": None, - "floor": 62, - "location": [ - 13.300807478789807, - 53.032327801732634 - ], - "polygons": None - }, - { - "type": "assembly", - "id": "fa1f2c57-fd54-47e6-add4-e483654b6741", - "slug": "child", - "name": "Assembly des Habitats #23", - "is_official": False, - "description": { - "de": None, - "en": "Sometimes, an example is all you get." - }, - "public_url": "https://hub.example.net/assembly/child/", - "parent_id": "037c0b9c-6283-42e4-9c43-24e072e8718e", - "children": None, - "floor": 2, - "location": [ - 12.997614446551779, - 53.040472311905404 - ], - "polygons": [ - [ - [ - 13.308124544982434, - 53.03139871248601 - ], - [ - 13.308231833342973, - 53.03173421924521 - ], - [ - 13.309240343932686, - 53.03166969891683 - ], - [ - 13.309197428588305, - 53.031218053919105 - ], - [ - 13.308146002654183, - 53.03123095812731 - ], - [ - 13.308124544982434, - 53.03139871248601 - ] - ] - ] - } - ] + with MapUpdate.lock(): + changed_geometries.reset() + self.do_import(r.json()) + MapUpdate.objects.create(type='importhub') + def do_import(self, data): items: list[HubImportItem] = [HubImportItem.model_validate(item) for item in data] items_by_id = {item.id: item for item in items} + spaces_for_level = {} + for space in Space.objects.all(): + spaces_for_level.setdefault(space.level_id, []).append(space) + + locations_so_far = { + **{poi.import_tag: poi for poi in POI.objects.filter(import_tag__startswith="hub:")}, + **{area.import_tag: area for area in Area.objects.filter(import_tag__startswith="hub:")}, + } + + groups_for_types = { + group.hub_import_type: group + for group in LocationGroup.objects.filter(hub_import_type__isnull=False) + } + for item in items: + if item.polygons is None and item.location is None: + print(f"SKIPPING: {item.slug} / {item.id} has no polygon or location") + continue + + if item.floor is None: + print(f"SKIPPING: {item.slug} / {item.id} has no floor") + continue + + import_tag = f"hub:{item.id}" + + # determine geometry + target_type = Area if item.polygons else POI + if target_type == Area: + new_geometry = shape({ + "type": "Polygon", + "coordinates": [[[y, x] for x, y in item.polygons[0]]], + }) + elif target_type == POI: + new_geometry = shape({ + "type": "Point", + "coordinates": list(reversed(item.location)), + }) + else: + raise ValueError + + # determine space + try: + possible_spaces = [space for space in spaces_for_level[item.floor] + if space.geometry.intersects(new_geometry)] + except KeyError: + print(f"ERROR: {item.slug} / {item.id} has invalid level ID") + continue + if not possible_spaces: + print(f"ERROR: {item.slug} / {item.id} is not within any space") + continue + if len(possible_spaces) == 1: + new_space = possible_spaces[0] + elif target_type == Area: + new_space = max(possible_spaces, key=lambda s: s.geometry.intersection(new_geometry).area) + elif target_type == POI: + print(f"WARNING: {item.slug} / {item.id} could be in multiple spaces, picking one...") + new_space = possible_spaces[0] + else: + raise ValueError + + # find existing location + result = locations_so_far.pop(import_tag, None) + result: Area | POI | None + + if result is not None: + # already exists + if not isinstance(result, target_type): + # need to change from POI to Area or inverted + if result.import_block_geom: + print(f"ERROR: {item.slug} / {item.id} needs to be switched to {target_type} but is blocked") + continue + print(f"ERROR: {item.slug} / {item.id} needs to be switched to {target_type} but not implemented") + continue + hub_types = [ item.type, - "%s:%s" % (item.type, f"parent:{items_by_id[item.parent_id].slug}" if item.parent_id else "no-parent") + "%s:%s" % (item.type, "with-children" if item.children else "with-no-children"), + "%s:%s" % (item.type, f"parent:{items_by_id[item.parent_id].slug}" if item.parent_id else "no-parent"), ] - print(hub_types) - pprint(r.json()) \ No newline at end of file + + # build groups + new_groups = [group for hub_type, group in groups_for_types.items() if hub_type in hub_types] + + # build resulting object + is_new = False + if not result: + is_new = True + result = target_type( + import_tag=import_tag, + ) + + if result.space_id != new_space.pk: + if result.import_block_geom: + print(f"NOTE: {item.slug} / {item.id} space has changed but is blocked") + else: + result.space_id = new_space.pk + + if result.geometry != new_geometry or True: + if result.import_block_geom: + print(f"NOTE: {item.slug} / {item.id} geometry has changed but is blocked") + else: + result.geometry = new_geometry + + if target_type == Area: + new_main_point = Point(item.location) if item.location else None + if result.main_point != new_main_point: + if result.import_block_geom: + print(f"NOTE: {item.slug} / {item.id} main point has changed but is blocked") + else: + result.main_point = new_main_point + + if item.slug != result.slug: + if result.import_block_data: + print(f"NOTE: {item.slug} / {item.id} slug has changed but is blocked") + else: + if LocationSlug.objects.filter(slug=item.slug): + print(f"ERROR: {item.slug} / {item.id} slug {item.slug!r} is already occupied") + continue + else: + result.slug = item.slug + + new_titles = {"en": item.name} + if new_titles != result.titles: + if result.import_block_data: + print(f"NOTE: {item.slug} / {item.id} name has changed but is blocked") + else: + result.titles = new_titles + + if item.public_url != result.external_url: + if result.import_block_data: + print(f"NOTE: {item.slug} / {item.id} external url has changed but is blocked") + else: + result.external_url = item.public_url + + # time to check the groups + new_group_ids = set(group.id for group in new_groups) + if is_new: + if not new_group_ids: + print(f"SKIPPING: {item.slug} / {item.id} has no group IDs, {hub_types}") + continue + else: + if not new_group_ids: + print(f"SKIPPING: {item.slug} / {item.id} no longer has any group IDs, {hub_types}") + continue + + result.save() + if is_new: + result.groups.set(new_group_ids) + else: + old_group_ids = set(group.pk for group in result.groups.all()) + if new_group_ids != old_group_ids: + print(f"NOTE: {item.slug} / {item.id} groups have changed, was " + + str([group.title for group in result.groups.all()]) + + ", is now"+str([group.title for group in new_groups]), new_group_ids, old_group_ids) + + for import_tag, location in locations_so_far.items(): + print(f"NOTE: {location.slug} / {import_tag} should be deleted") + diff --git a/src/c3nav/mapdata/migrations/0096_merge_20231225_2216.py b/src/c3nav/mapdata/migrations/0096_merge_20231225_2216.py new file mode 100644 index 00000000..f2715ee1 --- /dev/null +++ b/src/c3nav/mapdata/migrations/0096_merge_20231225_2216.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.7 on 2023-12-25 21:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('mapdata', '0095_accesspermission_for_session'), + ('mapdata', '0095_import_block'), + ] + + operations = [ + ] diff --git a/src/c3nav/mapdata/migrations/0097_longer_import_tag.py b/src/c3nav/mapdata/migrations/0097_longer_import_tag.py new file mode 100644 index 00000000..bdc23612 --- /dev/null +++ b/src/c3nav/mapdata/migrations/0097_longer_import_tag.py @@ -0,0 +1,93 @@ +# Generated by Django 4.2.7 on 2023-12-25 21:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mapdata', '0096_merge_20231225_2216'), + ] + + operations = [ + migrations.AlterField( + model_name='altitudearea', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='altitudemarker', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='area', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='building', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='column', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='door', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='graphnode', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='hole', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='lineobstacle', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='obstacle', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='poi', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='ramp', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='rangingbeacon', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='space', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='stair', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + migrations.AlterField( + model_name='wifimeasurement', + name='import_tag', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag'), + ), + ] diff --git a/src/c3nav/mapdata/models/geometry/base.py b/src/c3nav/mapdata/models/geometry/base.py index 0e9c00f6..084c1b1f 100644 --- a/src/c3nav/mapdata/models/geometry/base.py +++ b/src/c3nav/mapdata/models/geometry/base.py @@ -24,7 +24,7 @@ class GeometryMixin(SerializableMixin): geometry: BaseGeometry level_id: int subtitle: str - import_tag = models.CharField(_('import tag'), null=True, blank=True, max_length=32) + import_tag = models.CharField(_('import tag'), null=True, blank=True, max_length=64) class Meta: abstract = True