diff --git a/docs/POST_INSTALL_STEPS.md b/docs/POST_INSTALL_STEPS.md index 6f9fe61e..6df5e9c0 100644 --- a/docs/POST_INSTALL_STEPS.md +++ b/docs/POST_INSTALL_STEPS.md @@ -158,7 +158,7 @@ You cannot currently enable specific events, you either get "all or nothing". * "TRELLO_ID_LIST" - [Deprecated] ### Space Directory - * "ENABLE_SPACE_DIRECTORY" - enable a space directory compliant API. The various configuration options in this section should be self explannatory. + * "ENABLE_SPACE_DIRECTORY" - enable a [space directory compliant API](https://spaceapi.io). The various configuration options in this section should be self explanatory, however there is also an [API Endpoint](/docs/SPACEDIRECTORY) to update certain fields. ### Theme Swipe Integration * "THEME_SWIPE_URL" - a URL to hit on each door/interlock swipe that can trigger a theme song played over your intercom system, or something else. diff --git a/docs/SPACEDIRECTORY.md b/docs/SPACEDIRECTORY.md new file mode 100644 index 00000000..6ee1c5da --- /dev/null +++ b/docs/SPACEDIRECTORY.md @@ -0,0 +1,191 @@ +# Space Directory + +The Space Directory functionality is provided via the [SpaceAPI](https://spaceapi.io) format. + +## Reading the data + +Any system can read the current information about the space by calling `https:///api/spacedirectory/`. + +By default, this returns the following data: + +| Name | Description | Configuration Location | +|------|-------------|------------------------| +| space| The name of the space | Constance Config (Django Admin pages) | +| logo | The uri of the space logo | Constance Config (Django Admin Pages)| +| url | The URL of the membermatters installation | Django Base URL | +| contact | Contact information, including email and various social media handles | Constance Config (Django Admin Pages)| +| spacefed | Details of whether the Federated Hackspace Authentication service is running | Constance Config (Django Admin Pages)| +| projects | A list of projects that the space is involved in | Constance Config (Django Admin Pages)| +| issue_report_channels | What's the best way to report an issue? | Constance Config (Django Admin Pages)| +| state | Is the state open or closed, and what message should we display? | Dynamic (Django Admin or API Endpoint) | +| icon | An icon for each of "open" and "closed" | Constance Config (Django Admin Pages)| +| api_compatibility | The version of the [SpaceAPI](https://spaceapi.io) that we are compatible with (can be multiple values) | Hard-coded based on Member Matters release version | +| sensors | A dictionary of sensor data (and associated properties where relevant) as described in the [SpaceAPI JSON Schema documentation](https://spaceapi.io/docs/#schema-key-sensors) | Dynamic (Django Admin or API Endpoint) | +| location | The physical location of the space including latitude and longitude | Constance Config (Django Admin Pages)| + +The data is returned as a JSON document. + +For tools that can help you interact with the data to display your current state on your homepage etc, visit the [SpaceAPI Tools](https://spaceapi.io/how-to-use/) page and have a play! + +## Updating the data + +For anything in table above that is marked as "dynamic", you can update that information via the API. + +Simply `POST` a JSON document to `https:///api/spacedirectory/update` as an authenticated user, and the relevant fields will be updated. + +### Updating the status and the message + +Let's say you're opening the space and you want to ensure that everyone knows it's an "Open Night" (i.e. general public are welcome, not just members). + +You can do this with the following command: + +```bash +curl -X POST -H 'Authorization: Api-Key ' \ + -d '{"is_open": true, "message": "Open Night TONIGHT! All Welcome between 1800hrs and 2300hrs"}' \ + https:///api/spacedirectory/update +``` + +### Adding Sensor Data + +[Sensors](https://spaceapi.io/docs/#schema-key-sensors) are a really cool part of the SpaceAPI schema, as it allows you to publish all kinds of things from how many drinks are still in the fridge through to environment readings such as temperature, windspeed, and humidity. + +By default, MemberMatters exposes the total number of active members and how many have "signed in" to the space, however you can add your own sensors as long as they conform to the appropriate type. + +To update a sensor or property value, you just need to POST the appropriate JSON to `https:///api/spacedirectory/update` as laid out below. + +**NOTE**: If a sensor or property is missing from the database then it will be created. If it exists but the value is not included or changed, it will not be updated. + +#### Sensors *without* properties + +Many of the sensors do not have any extra properties. Updating these is simple, as you just need to send a JSON array of dicts with the correct fields filled out: + +```json +{ + "sensors": [ + { + "type": "temperature", + "name": "test_sensor", + "location": "default", + "description": "This is a sensor", + "unit": "°C", + "value": 21.0 + } + ] +} +``` + +Want to update more than one sensor at a time? No worries, just add to the array: + +```json +{ + "sensors": [ + { + "type": "temperature", + "name": "test_sensor", + "location": "default", + "description": "This is a sensor", + "unit": "°C", + "value": 21.0 + }, + { + "type": "humidity", + "name": "test_sensor", + "location": "default", + "description": "This is a sensor", + "unit": "%H", + "value": 45.0 + } + ] +} +``` + +#### Sensors *with* properties + +For those sensors that do have properties, only a few extra fields are required: + +```json +{ + "sensors": [ + { + "type": "wind", + "name": "wind_sensor_01", + "location": "The Roof", + "description": "A weather station", + "properties": [ + { + "name": "gust", + "unit": "m/s", + "speed": 5.0 + } + ] + } + ] +} +``` + +As with the sensors, additional properties can be created by adding more dictionaries to the properties array: + + +```json +{ + "sensors": [ + { + "type": "wind", + "name": "wind_sensor_01", + "location": "The Roof", + "description": "A weather station", + "properties": [ + { + "name": "speed", + "unit": "m/s", + "speed": 5.0 + }, + { + "name": "gust", + "unit": "m/s", + "speed": 9.0 + } + ] + } + ] +} +``` + +### A full example + +The following JSON updates the space status to "Open", sets a message advising that there is a workshop running that evening, and sets the values for various sensors: + +```json + +{ "is_open": true, + "message": "Soldering workshop tonight - 8pm to 10pm" + "sensors": [ + { + "type": "temperature", + "name": "test_sensor", + "location": "default", + "description": "This is a sensor", + "unit": "°C", + "value": 21.0 + }, + { + "type": "wind", + "name": "wind_sensor_01", + "location": "The Roof", + "description": "A weather station", + "properties": [ + { + "name": "speed", + "unit": "m/s", + "speed": 5.0 + }, + { + "name": "gust", + "unit": "m/s", + "speed": 9.0 + } + ] + } + ] +} +``` diff --git a/memberportal/api_spacedirectory/admin.py b/memberportal/api_spacedirectory/admin.py new file mode 100644 index 00000000..c6745475 --- /dev/null +++ b/memberportal/api_spacedirectory/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin +from .models import SpaceAPI, SpaceAPISensor, SpaceAPISensorProperties + + +@admin.register(SpaceAPI) +class SpaceAPIAdmin(admin.ModelAdmin): + pass + + +@admin.register(SpaceAPISensor) +class SpaceAPISensorAdmin(admin.ModelAdmin): + pass + + +@admin.register(SpaceAPISensorProperties) +class SpaceAPISensorPropertiesAdmin(admin.ModelAdmin): + pass diff --git a/memberportal/api_spacedirectory/migrations/0001_initial.py b/memberportal/api_spacedirectory/migrations/0001_initial.py new file mode 100644 index 00000000..3c32d48a --- /dev/null +++ b/memberportal/api_spacedirectory/migrations/0001_initial.py @@ -0,0 +1,98 @@ +# Generated by Django 3.2.21 on 2023-10-12 09:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="SpaceAPI", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("space_is_open", models.BooleanField(default=False)), + ( + "space_message", + models.CharField(blank=True, max_length=255, null=True), + ), + ("status_last_change", models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name="SpaceAPISensor", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "sensor_type", + models.CharField( + choices=[ + ("temperature", "Temperature"), + ("barometer", "Barometer"), + ("radiation", "Radiation"), + ("humidity", "Humidity"), + ("beverage_supply", "Beverage Supply"), + ("power_consumption", "Power Consumption"), + ("wind", "Wind Data"), + ("network_connections", "Network Connections"), + ("account_balance", "Account Balance"), + ("network_traffic", "Network Traffic"), + ], + max_length=100, + ), + ), + ("name", models.CharField(max_length=255)), + ("value", models.DecimalField(decimal_places=3, max_digits=10)), + ("unit", models.CharField(max_length=50)), + ("location", models.CharField(blank=True, max_length=255, null=True)), + ( + "description", + models.CharField(blank=True, max_length=255, null=True), + ), + ], + ), + migrations.CreateModel( + name="SpaceAPISensorProperties", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("value", models.DecimalField(decimal_places=3, max_digits=10)), + ("unit", models.CharField(max_length=100)), + ( + "sensor_id", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="properties", + to="api_spacedirectory.spaceapisensor", + ), + ), + ], + ), + ] diff --git a/memberportal/api_spacedirectory/migrations/0002_auto_20231012_1914.py b/memberportal/api_spacedirectory/migrations/0002_auto_20231012_1914.py new file mode 100644 index 00000000..3472feca --- /dev/null +++ b/memberportal/api_spacedirectory/migrations/0002_auto_20231012_1914.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.21 on 2023-10-12 09:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api_spacedirectory", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="spaceapisensor", + name="unit", + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name="spaceapisensor", + name="value", + field=models.DecimalField( + blank=True, decimal_places=3, max_digits=10, null=True + ), + ), + ] diff --git a/memberportal/api_spacedirectory/migrations/__init__.py b/memberportal/api_spacedirectory/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/memberportal/api_spacedirectory/models.py b/memberportal/api_spacedirectory/models.py new file mode 100644 index 00000000..78801649 --- /dev/null +++ b/memberportal/api_spacedirectory/models.py @@ -0,0 +1,62 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.conf import settings + + +class SpaceAPI(models.Model): + # The Hackspace is always closed by default to prevent people from showing up by accident. + space_is_open = models.BooleanField(default=False) + space_message = models.CharField(max_length=255, blank=True, null=True) + status_last_change = models.DateTimeField(auto_now=True) + + def clean(self): + if SpaceAPI.objects.exists() and not self.pk: + raise ValidationError("You can only have one SpaceAPI Setting") + + def __str__(self): + open_text = "CLOSED" + if self.space_is_open is True: + open_text = "OPEN" + + return f"Currently {open_text} - last updated: {self.status_last_change}" + + +# Because Sensors are tied to a space, and we only have one space in the system for now +# We have the luxury of not requiring foreign keys here! +class SpaceAPISensor(models.Model): + SENSOR_TYPE_CHOICES = [ + ("temperature", "Temperature"), + ("barometer", "Barometer"), + ("radiation", "Radiation"), + ("humidity", "Humidity"), + ("beverage_supply", "Beverage Supply"), + ("power_consumption", "Power Consumption"), + ("wind", "Wind Data"), + ("network_connections", "Network Connections"), + ("account_balance", "Account Balance"), + ("network_traffic", "Network Traffic"), + ] + sensor_type = models.CharField(max_length=100, choices=SENSOR_TYPE_CHOICES) + name = models.CharField( + max_length=255 + ) # The sensor name ("main door", "external", etc) + value = models.DecimalField(max_digits=10, decimal_places=3, blank=True, null=True) + unit = models.CharField(max_length=50, blank=True, null=True) + location = models.CharField(max_length=255, blank=True, null=True) + description = models.CharField(max_length=255, blank=True, null=True) + + def __str__(self): + return f"{self.name} - {self.location} ({self.sensor_type})" + + +# Some sensors have properties, let's track those in a separate model +class SpaceAPISensorProperties(models.Model): + sensor_id = models.ForeignKey( + SpaceAPISensor, related_name="properties", on_delete=models.CASCADE + ) + name = models.CharField(max_length=100) + value = models.DecimalField(max_digits=10, decimal_places=3) + unit = models.CharField(max_length=100) + + def __str__(self): + return f"{self.sensor_id.name}/{self.name}" diff --git a/memberportal/api_spacedirectory/urls.py b/memberportal/api_spacedirectory/urls.py index ef50b158..b8229808 100644 --- a/memberportal/api_spacedirectory/urls.py +++ b/memberportal/api_spacedirectory/urls.py @@ -7,4 +7,9 @@ views.SpaceDirectoryStatus.as_view(), name="spacedirectory_status", ), + path( + "api/spacedirectory/update", + views.SpaceDirectoryUpdate.as_view(), + name="spacedirectory_update", + ), ] diff --git a/memberportal/api_spacedirectory/views.py b/memberportal/api_spacedirectory/views.py index 6776084a..3bb15975 100644 --- a/memberportal/api_spacedirectory/views.py +++ b/memberportal/api_spacedirectory/views.py @@ -1,7 +1,11 @@ from rest_framework.response import Response from rest_framework import status, permissions +from rest_framework_api_key.permissions import HasAPIKey from rest_framework.views import APIView from constance import config +from .models import SpaceAPI, SpaceAPISensor, SpaceAPISensorProperties +from profile.models import Profile +from api_general.models import SiteSession import json @@ -18,38 +22,188 @@ def get(self, request): ) else: - return Response( - { - "state": { - "open": config.SPACE_DIRECTORY_OPEN, - "message": config.SPACE_DIRECTORY_MESSAGE, - "icon": { - "open": config.SPACE_DIRECTORY_ICON_OPEN, - "closed": config.SPACE_DIRECTORY_ICON_CLOSED, - }, - }, - "api": "0.13", - "location": { - "address": config.SPACE_DIRECTORY_LOCATION_ADDRESS, - "lat": config.SPACE_DIRECTORY_LOCATION_LAT, - "lon": config.SPACE_DIRECTORY_LOCATION_LON, - }, - "space": config.SITE_OWNER, - "logo": config.SITE_LOGO, - "url": config.MAIN_SITE_URL, - "spacefed": { - "spacenet": config.SPACE_DIRECTORY_FED_SPACENET, - "spacesaml": config.SPACE_DIRECTORY_FED_SPACESAML, - "spacephone": config.SPACE_DIRECTORY_FED_SPACEPHONE, - }, - "cam": json.loads(config.SPACE_DIRECTORY_CAMS), - "contact": { - "email": config.SPACE_DIRECTORY_CONTACT_EMAIL, - "twitter": config.SPACE_DIRECTORY_CONTACT_TWITTER, - "phone": config.SPACE_DIRECTORY_CONTACT_PHONE, - "facebook": config.SPACE_DIRECTORY_CONTACT_FACEBOOK, - }, - "projects": json.loads(config.SPACE_DIRECTORY_PROJECTS), - "issue_report_channels": ["email"], + spaceapi = { + "space": config.SITE_OWNER, + "logo": config.SITE_LOGO, + "url": config.MAIN_SITE_URL, + "contact": { + "email": config.SPACE_DIRECTORY_CONTACT_EMAIL, + "twitter": config.SPACE_DIRECTORY_CONTACT_TWITTER, + "phone": config.SPACE_DIRECTORY_CONTACT_PHONE, + "facebook": config.SPACE_DIRECTORY_CONTACT_FACEBOOK, + }, + "spacefed": { + "spacenet": config.SPACE_DIRECTORY_FED_SPACENET, + "spacesaml": config.SPACE_DIRECTORY_FED_SPACESAML, + "spacephone": config.SPACE_DIRECTORY_FED_SPACEPHONE, + }, + "projects": json.loads(config.SPACE_DIRECTORY_PROJECTS), + "issue_report_channels": ["email"], + } + + # Get the default data + spaceapi_data = SpaceAPI.objects.get() + + # Get a list of all the sensors + spaceapi_sensors = SpaceAPISensor.objects.all() + + # Create an empty dict to add the sensor data to + sensor_data = {} + + # Iterate over the sensors and update the dict as appropriate + for sensor in spaceapi_sensors: + sensor_details = {} + # Do we already have a sensor of this type? If not, create it now + if sensor.sensor_type not in sensor_data: + sensor_data[sensor.sensor_type] = [] + + ## Setup the basic details + sensor_details = { + "name": sensor.name, + "description": sensor.description, + "location": sensor.location, } - ) + + ### Do we have properties? If so, let's add them + if len(sensor.properties.all()) > 0: + sensor_details["properties"] = {} + for prop in sensor.properties.all(): + properties = { + prop.name: {"value": prop.value, "unit": prop.unit} + } + sensor_details["properties"].update(properties) + else: + sensor_details.update({"value": sensor.value, "unit": sensor.unit}) + + sensor_data[sensor.sensor_type].append(sensor_details) + + ## Add the user count and members on site count to the sensors + spaceapi_user_count = Profile.objects.all().filter(state="active").count() + spaceapi_members_on_site = SiteSession.objects.filter( + signout_date=None + ).order_by("-signin_date") + + sensor_data["total_member_count"] = {"value": spaceapi_user_count} + + sensor_data["people_now_present"] = { + "value": spaceapi_members_on_site.count() + } + + # Is the camera array empty? If not, add them + if not config.SPACE_DIRECTORY_CAMS: + spaceapi["cameras"] = config.SPACE_DIRECTORY_CAMS + + # Set the STATE part of the schema, the icons, and the schema version + spaceapi["state"] = { + "open": spaceapi_data.space_is_open, + "message": spaceapi_data.space_message, + "lastchange": spaceapi_data.status_last_change.timestamp(), + } + spaceapi["icon"] = { + "open": config.SPACE_DIRECTORY_ICON_OPEN, + "closed": config.SPACE_DIRECTORY_ICON_CLOSED, + } + spaceapi["api_compatibility"] = ["0.14"] + + ## Add the sensor data to the main body of the schema + spaceapi["sensors"] = sensor_data + + ## Add the location data based on the values in Constance + spaceapi["location"] = { + "address": config.SPACE_DIRECTORY_LOCATION_ADDRESS, + "lat": config.SPACE_DIRECTORY_LOCATION_LAT, + "lon": config.SPACE_DIRECTORY_LOCATION_LON, + } + + # Return the JSON document + return Response(spaceapi) + + +class SpaceDirectoryUpdate(APIView): + """Allows authenticated users to update the SpaceAPI information""" + + permission_classes = (permissions.IsAdminUser | HasAPIKey,) + + def post(self, request): + # Get the current state of the space + current_status = SpaceAPI.objects.get() + + ## Do we need to update the open/closed status + if "is_open" in request.data: + current_status.space_is_open = request.data["is_open"] + + ## Do we need to update the message? + if "message" in request.data: + current_status.space_message = request.data["message"] + + ## Do we have any sensor data? + if "sensors" in request.data: + for sensor in request.data["sensors"]: + ### See if we have an existing sensor, if we do, update it, + ### if we don't, create it. + try: + current_sensor = ( + SpaceAPISensor.objects.all().filter(name=sensor["name"]).first() + ) + print(f"Found sensor: {current_sensor}") + if "type" in sensor: + current_sensor.sensor_type = sensor["type"] + if "value" in sensor: + current_sensor.value = sensor["value"] + if "unit" in sensor: + current_sensor.unit = sensor["unit"] + if "location" in sensor: + current_sensor.location = sensor["location"] + if "description" in sensor: + current_sensor.description = sensor["description"] + + if "properties" in sensor: + for prop in sensor["properties"]: + #### Does the property exist? If so, update, + #### if not, create + try: + current_prop = SpaceAPISensorProperties.objects.filter( + name=prop["name"], sensor_id=current_sensor + ).get() + print(f"Found property: {current_prop}") + if "name" in prop: + current_prop.name = prop["name"] + if "value" in prop: + current_prop.value = prop["value"] + if "unit" in prop: + current_prop.unit = prop["unit"] + current_prop.save() + except: + new_prop = SpaceAPISensorProperties() + new_prop.name = prop["name"] + new_prop.value = prop["value"] + new_prop.unit = prop["unit"] + new_prop.sensor_id = current_sensor + new_prop.save() + + current_sensor.save() + except Exception as e: + new_sensor = SpaceAPISensor() + new_sensor.sensor_type = sensor["type"] + new_sensor.name = sensor["name"] + if "value" in sensor: + new_sensor.value = sensor["value"] + if "unit" in sensor: + new_sensor.unit = sensor["unit"] + new_sensor.location = sensor["location"] + new_sensor.description = sensor["description"] + new_sensor.save() + + if "properties" in sensor: + ## This is a new sensor, we will always want to create the properties + for prop in sensor["properties"]: + new_prop = SpaceAPISensorProperties() + new_prop.name = prop["name"] + new_prop.value = prop["value"] + new_prop.unit = prop["unit"] + new_prop.sensor_id = new_sensor + new_prop.save() + + current_status.save() + + return Response()