diff --git a/.gitignore b/.gitignore index dbfd0bc..2b06ffc 100644 --- a/.gitignore +++ b/.gitignore @@ -154,4 +154,5 @@ dmypy.json cython_debug/ # docker compose production environment -docker-compose.prod.yml \ No newline at end of file +docker-compose.prod.yml +db_backup \ No newline at end of file diff --git a/app/api/migrations/0002_alter_fk.py b/app/api/migrations/0002_alter_fk.py new file mode 100644 index 0000000..3ef94bb --- /dev/null +++ b/app/api/migrations/0002_alter_fk.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.2 on 2024-12-09 08:58 + +from django.db import migrations, models +from devices.models import Sensor + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ('devices', '0004_alter_device_model_remove_device_sensor_bme280_and_more') + ] + # TODO: migrate data aswell + operations = [ + migrations.RemoveField( + model_name='measurement', + name='sensor', + ), + migrations.AddField( + model_name='measurement', + name='sensor', + field=models.ForeignKey( + to='devices.Sensor', + on_delete=models.CASCADE, + ), + ), + ] diff --git a/app/api/migrations/0003_devicelogs_measurementnew_values.py b/app/api/migrations/0003_devicelogs_measurementnew_values.py new file mode 100644 index 0000000..a2be83f --- /dev/null +++ b/app/api/migrations/0003_devicelogs_measurementnew_values.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.2 on 2024-12-09 11:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_alter_fk'), + ('campaign', '0002_alter_campaign_id_alter_campaign_name_organization_and_more'), + ('devices', '0004_alter_device_model_remove_device_sensor_bme280_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='DeviceLogs', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False)), + ('timestamp', models.DateTimeField()), + ('level', models.IntegerField()), + ('message', models.TextField()), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='devices.device')), + ], + ), + migrations.CreateModel( + name='MeasurementNew', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False)), + ('time_received', models.DateTimeField()), + ('time_measured', models.DateTimeField()), + ('sensor_model', models.IntegerField()), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='devices.device')), + ('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='campaign.room')), + ], + ), + migrations.CreateModel( + name='Values', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False)), + ('dimension', models.IntegerField()), + ('value', models.FloatField()), + ('measurement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.measurementnew')), + ], + ), + ] diff --git a/app/api/models.py b/app/api/models.py index feb4f99..b8bb356 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -1,6 +1,7 @@ from django.db import models from workshops.models import Participant, Workshop from devices.models import Device, Sensor +from campaign.models import Room #from campaign.models import Campaign @@ -75,3 +76,45 @@ class AirQualityRecord(models.Model): lon = models.FloatField(null=True, blank=True) location_precision = models.FloatField(null=True, blank=True) mode = models.ForeignKey(MobilityMode, on_delete=models.CASCADE, null=True, blank=True) + + +class MeasurementNew(models.Model): + """ + Measurement taken by a device in a room. + """ + id = models.IntegerField(primary_key=True) + time_received = models.DateTimeField() + time_measured = models.DateTimeField() + sensor_model = models.IntegerField() + device = models.ForeignKey(Device, on_delete=models.CASCADE) + room = models.ForeignKey(Room, on_delete=models.CASCADE) + + def __str__(self): + return f'Measurement {self.id} from Device {self.device.id}' + + +class Values(models.Model): + """ + Values associated with a measurement. + """ + id = models.IntegerField(primary_key=True) + dimension = models.IntegerField() + value = models.FloatField() + measurement = models.ForeignKey(MeasurementNew, on_delete=models.CASCADE) + + def __str__(self): + return f'Value {self.id} for Measurement {self.measurement.id}' + + +class DeviceLogs(models.Model): + """ + Logs for each device. + """ + id = models.IntegerField(primary_key=True) + device = models.ForeignKey(Device, on_delete=models.CASCADE) + timestamp = models.DateTimeField() + level = models.IntegerField() + message = models.TextField() + + def __str__(self): + return f'Log {self.id} for Device {self.device.id}' \ No newline at end of file diff --git a/app/campaign/migrations/0002_alter_campaign_id_alter_campaign_name_organization_and_more.py b/app/campaign/migrations/0002_alter_campaign_id_alter_campaign_name_organization_and_more.py new file mode 100644 index 0000000..2828efb --- /dev/null +++ b/app/campaign/migrations/0002_alter_campaign_id_alter_campaign_name_organization_and_more.py @@ -0,0 +1,74 @@ +# Generated by Django 5.1.2 on 2024-12-09 11:06 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('campaign', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='campaign', + name='id', + field=models.IntegerField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='campaign', + name='name', + field=models.CharField(max_length=255), + ), + migrations.CreateModel( + name='Organization', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_organizations', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='campaign', + name='organization', + field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='campaigns', to='campaign.organization'), + preserve_default=False, + ), + migrations.CreateModel( + name='Room', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rooms', to='campaign.campaign')), + ], + options={ + 'unique_together': {('campaign', 'name')}, + }, + ), + migrations.CreateModel( + name='UserCampaign', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='campaign.campaign')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'campaign')}, + }, + ), + migrations.CreateModel( + name='UserOrganization', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='campaign.organization')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'organization')}, + }, + ), + ] diff --git a/app/campaign/models.py b/app/campaign/models.py index 52b6f65..44008de 100644 --- a/app/campaign/models.py +++ b/app/campaign/models.py @@ -4,7 +4,11 @@ import random class Campaign(models.Model): - name = models.CharField(max_length=100) + """ + Represents a campaign. + """ + id = models.IntegerField(primary_key=True) + name = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) start_date = models.DateTimeField() end_date = models.DateTimeField() @@ -13,25 +17,74 @@ class Campaign(models.Model): owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, - related_name='owned_campaigns', # Allows user.owned_campaigns.all() to get all owned workshops + related_name='owned_campaigns', null=True, blank=True ) + organization = models.ForeignKey( + 'Organization', + on_delete=models.CASCADE, + related_name='campaigns' + ) def __str__(self): return self.name - - def save(self, *args, **kwargs): - if not self.id_token: - # Generate a unique ID for new workshops - self.id_token = self.generate_id_token() - super(Campaign, self).save(*args, **kwargs) - - @staticmethod - def generate_id_token(): - length = 8 - # Ensure the generated ID is unique - while Campaign.objects.filter(id_token=id_token).exists(): - # Generate a random string of letters and digits - id_token = ''.join(random.choices(string.ascii_letters + string.digits, k=length)) - return id_token \ No newline at end of file + + +class Room(models.Model): + """ + Represents a room where a campaign takes place. + """ + campaign = models.ForeignKey('Campaign', on_delete=models.CASCADE, related_name='rooms') + name = models.CharField(max_length=255) + + class Meta: + unique_together = ('campaign', 'name') + + def __str__(self): + return f'{self.name} in {self.campaign}' + + +class Organization(models.Model): + """ + Represents an organization that owns campaigns and users can be part of. + """ + id = models.IntegerField(primary_key=True) + name = models.CharField(max_length=255) + description = models.TextField(null=True, blank=True) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='owned_organizations' + ) + + def __str__(self): + return self.name + + +class UserOrganization(models.Model): + """ + Represents the many-to-many relationship between users and organizations. + """ + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + organization = models.ForeignKey(Organization, on_delete=models.CASCADE) + + class Meta: + unique_together = ('user', 'organization') + + def __str__(self): + return f'{self.user} in {self.organization}' + + +class UserCampaign(models.Model): + """ + Represents the many-to-many relationship between users and campaigns. + """ + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + campaign = models.ForeignKey('Campaign', on_delete=models.CASCADE) + + class Meta: + unique_together = ('user', 'campaign') + + def __str__(self): + return f'{self.user} in {self.campaign}' diff --git a/app/devices/migrations/0004_alter_device_model_remove_device_sensor_bme280_and_more.py b/app/devices/migrations/0004_alter_device_model_remove_device_sensor_bme280_and_more.py new file mode 100644 index 0000000..e8bc65f --- /dev/null +++ b/app/devices/migrations/0004_alter_device_model_remove_device_sensor_bme280_and_more.py @@ -0,0 +1,94 @@ +# Generated by Django 5.1.2 on 2024-12-09 07:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('devices', '0003_remove_device_name_device_device_name_and_more'), + ('api', '0001_initial') + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='model', + field=models.CharField(blank=True, max_length=255, null=True), + preserve_default=False, + ), + migrations.RemoveField( + model_name='device', + name='sensor_bme280', + ), + migrations.RemoveField( + model_name='device', + name='sensor_bme680', + ), + migrations.RemoveField( + model_name='device', + name='sensor_sen5x', + ), + migrations.RemoveField( + model_name='sensor', + name='description', + ), + migrations.AddField( + model_name='device', + name='firmware', + field=models.CharField(blank=True, max_length=12), + ), + migrations.AddField( + model_name='sensor', + name='firmware', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='sensor', + name='hardware', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='sensor', + name='product_type', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='sensor', + name='protocol', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='sensor', + name='serial', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AlterField( + model_name='device', + name='device_name', + field=models.CharField(blank=True, max_length=255, null=True), + preserve_default=False, + ), + migrations.AlterField( + model_name='device', + name='id', + field=models.CharField(max_length=255, primary_key=True, serialize=False), + ), + migrations.RunSQL( + "ALTER TABLE api_measurement DROP CONSTRAINT api_measurement_sensor_id_8039993a_fk_devices_sensor_name;", # PostgreSQL example + "ALTER TABLE api_measurement ADD CONSTRAINT api_measurement_sensor_id_8039993a_fk_devices_sensor_name FOREIGN KEY (sensor) REFERENCES devices_sensor(name);" + ), + migrations.AlterField( + model_name='sensor', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AddField( + model_name='sensor', + name='id', + field=models.AutoField(primary_key=True), + ), + migrations.DeleteModel( + name='DeviceModel', + ), + ] diff --git a/app/devices/migrations/0005_alter_sensor_id.py b/app/devices/migrations/0005_alter_sensor_id.py new file mode 100644 index 0000000..f5c746d --- /dev/null +++ b/app/devices/migrations/0005_alter_sensor_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2024-12-09 14:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('devices', '0004_alter_device_model_remove_device_sensor_bme280_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='sensor', + name='id', + field=models.AutoField(primary_key=True, serialize=False), + ), + ] diff --git a/app/devices/models.py b/app/devices/models.py index e037d60..8b20ed9 100644 --- a/app/devices/models.py +++ b/app/devices/models.py @@ -7,8 +7,8 @@ class Device(models.Model): Device model. """ id = models.CharField(max_length=255, primary_key=True) - device_name = models.CharField(max_length=255, blank=True) - model = models.CharField(max_length=255, blank=True) + device_name = models.CharField(max_length=255, blank=True, null=True) + model = models.CharField(max_length=255, blank=True, null=True) firmware = models.CharField(max_length=12, blank=True) btmac_address = models.CharField(max_length=12, null=True, blank=True) last_update = models.DateTimeField(null=True, blank=True) diff --git a/app/main/settings.py b/app/main/settings.py index 8eea406..9893467 100644 --- a/app/main/settings.py +++ b/app/main/settings.py @@ -112,12 +112,12 @@ DATABASES = { 'default': { - "ENGINE": "django.db.backends.postgresql", - "NAME": "postgres", - "USER": "postgres", - "PASSWORD": "password", - "HOST": "db", - "PORT": 5432, + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': env('POSTGRES_DB', default='postgres'), + 'USER': env('POSTGRES_USER', default='postgres'), + 'PASSWORD': env('POSTGRES_PASSWORD', default='password'), + 'HOST': env('DB_HOST', default='db'), + 'PORT': env('POSTGRES_PORT', default=5432), } } diff --git a/datahub_er_model.puml b/datahub_er_model.puml new file mode 100644 index 0000000..98067b5 --- /dev/null +++ b/datahub_er_model.puml @@ -0,0 +1,103 @@ +@startuml +entity "Device" as Device { + + id : String [PK] + + device_name : String + + model : String + + firmware : String + + btmac_address : String + + last_update : DateTime + + notes : Text +} + +entity "Measurement" as Measurement { + + id : Integer [PK] + + time_received : DateTime + + time_measured : DateTime + + sensor_model : Integer + -- + + device_id : String [FK] + + room_id : Integer [FK] +} + +entity "Values" as Values { + + id : Integer [PK] + + dimension : Integer + + value : Float + -- + + measurement_id : Integer [FK] +} + +entity "DeviceLogs" as DeviceLogs { + + id : Integer [PK] + + device_id : String [FK] + + timestamp : DateTime + + level : Integer + + message : String + -- + + device_id : String [FK] +} + +entity "Room" as Room { + + campaign_id : Integer [PK, FK] + + name : String [PK] + -- + + campaign_id : Integer [FK] +} + +entity "Campaign" as Campaign { + + id : Integer [PK] + + name : String + + description : Text + + start_date : DateTime + + end_date : DateTime + + public : Boolean + + id_token : String [unique] + + owner_id : Integer [FK] + -- + + owner_id : Integer [FK] +} + +entity "DeviceStatus" as DeviceStatus { + + id : Integer [PK] + + time_received : DateTime + + battery_voltage : Float + + battery_soc : Float + + sensors : JSON + -- + + device_id : String [FK] +} + +entity "Organization" as Organization { + + id : Integer [PK] + + name : String + + description : Text + -- + + owner_id : Integer [FK] +} + +entity "UserOrganization" as UserOrganization { + + id : Integer [PK] + -- + + user_id : Integer [FK] + + organization_id : Integer [FK] +} + +entity "UserCampaign" as UserCampaign { + + id : Integer [PK] + -- + + user_id : Integer [FK] + + campaign_id : Integer [FK] +} + +Device ||--o{ Measurement : "records" +Measurement ||--o{ Values : "has" +Device ||--o{ DeviceLogs : "logs" +Device ||--o{ DeviceStatus : "has status" +Measurement ||--o{ Room : "takes place in" +Campaign ||--o{ Room : "contains" +UserOrganization ||--o{ User : "user is part of" +UserOrganization ||--o{ Organization : "organization has users" +UserCampaign ||--o{ User : "user participates in" +UserCampaign ||--o{ Campaign : "campaign has users" +Campaign ||--o{ Organization : "organization has campaigns" +@enduml \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 6d5f79a..b09957b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: db: image: postgres:16-alpine volumes: + - ./db_backup/read:/docker-entrypoint-initdb.d/:ro - postgres_data:/var/lib/postgresql/data/ env_file: - .env @@ -23,5 +24,16 @@ services: - "80:80" depends_on: - app + admin: + image: dpage/pgadmin4:latest + env_file: + - .env + depends_on: + - db + volumes: + - pgadmin-data:/var/lib/pgadmin/ + ports: + - "8080:80" volumes: - postgres_data: \ No newline at end of file + postgres_data: + pgadmin-data: \ No newline at end of file