simo 3.0.5__py3-none-any.whl → 3.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of simo might be problematic. Click here for more details.

@@ -216,6 +216,7 @@ class GatesHandler:
216
216
  class AutomationsGatewayHandler(GatesHandler, BaseObjectCommandsGatewayHandler):
217
217
  name = "Automation"
218
218
  config_form = BaseGatewayForm
219
+ auto_create = True
219
220
  info = "Provides various types of automation capabilities"
220
221
 
221
222
  running_scripts = {}
simo/core/api.py CHANGED
@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
7
7
  from django.utils import timezone
8
8
  from django.http import HttpResponse, Http404
9
9
  from simo.core.utils.helpers import get_self_ip, search_queryset
10
+ from simo.core.middleware import introduce_instance
10
11
  from rest_framework import status
11
12
  from actstream.models import Action
12
13
  from rest_framework.pagination import PageNumberPagination
@@ -41,6 +42,7 @@ class InstanceMixin:
41
42
  ).last()
42
43
  if not self.instance:
43
44
  raise Http404()
45
+ introduce_instance(self.instance)
44
46
  return super().dispatch(request, *args, **kwargs)
45
47
 
46
48
  def get_serializer_context(self):
@@ -140,7 +142,8 @@ class ZoneViewSet(InstanceMixin, viewsets.ModelViewSet):
140
142
  )
141
143
  for i, id in enumerate(request_data.get('zones')):
142
144
  zones[str(id)].order = i
143
- Zone.objects.bulk_update([z for id, z in zones.items()], fields=['order'])
145
+ for i, zone in zones.items():
146
+ zone.save()
144
147
  return RESTResponse({'status': 'success'})
145
148
 
146
149
 
@@ -1,21 +1,19 @@
1
- import random, time
2
1
  from django.contrib.gis.db.backends.postgis.base import (
3
2
  DatabaseWrapper as PostGisPsycopg2DatabaseWrapper
4
3
  )
5
- from django.db import close_old_connections, connection as db_connection
4
+ from django.db.utils import OperationalError, InterfaceError
6
5
  from django.utils.asyncio import async_unsafe
7
- from django.db.utils import InterfaceError
8
- from django.conf import settings
9
6
 
10
7
 
11
8
  class DatabaseWrapper(PostGisPsycopg2DatabaseWrapper):
12
-
13
9
  @async_unsafe
14
10
  def create_cursor(self, name=None):
15
- if not self.is_usable():
16
- close_old_connections()
17
- db_connection.connect()
18
- return super().create_cursor(name=name)
19
-
11
+ try:
12
+ return super().create_cursor(name=name)
13
+ except (InterfaceError, OperationalError):
14
+ # Heal this very connection
15
+ self.close()
16
+ self.connect()
17
+ return super().create_cursor(name=name)
20
18
 
21
19
 
simo/core/events.py CHANGED
@@ -52,8 +52,8 @@ class ObjectChangeEvent(ObjMqttAnnouncement):
52
52
  self.data.update(**kwargs)
53
53
 
54
54
  def get_topic(self):
55
- return f"{self.TOPIC}/{self.instance.id if self.instance else 'global'}/" \
56
- f"{type(self.obj).__name__}-{self.data['obj_pk']}"
55
+ return f"{self.TOPIC}/{self.instance.uid if self.instance else 'global'}/" \
56
+ f"{type(self.obj).__name__}/{self.data['obj_pk']}"
57
57
 
58
58
  def publish(self, retain=True):
59
59
  return super().publish(retain=retain)
@@ -91,6 +91,33 @@ def get_event_obj(payload, model_class=None, gateway=None):
91
91
  return obj
92
92
 
93
93
 
94
+ def dirty_fields_to_current_values(instance, dirty_fields):
95
+ """Return a mapping of changed field names to the instance's current values.
96
+
97
+ - Avoids extra DB hits by reading values from the in-memory instance.
98
+ - For ForeignKey fields, emits the underlying ``<field>_id`` when available
99
+ to keep the payload JSON-serializable and consistent.
100
+ """
101
+ if not dirty_fields:
102
+ return {}
103
+
104
+ current = {}
105
+ for field_name in dirty_fields.keys():
106
+ try:
107
+ model_field = instance._meta.get_field(field_name)
108
+ # Many-to-many changes are not handled here (not part of default dirty_fields)
109
+ if getattr(model_field, 'many_to_many', False):
110
+ continue
111
+ if getattr(model_field, 'is_relation', False) and hasattr(instance, f"{field_name}_id"):
112
+ current[field_name] = getattr(instance, f"{field_name}_id")
113
+ else:
114
+ current[field_name] = getattr(instance, field_name, None)
115
+ except Exception:
116
+ # Field might not be a real model field (unlikely); fallback to attribute
117
+ current[field_name] = getattr(instance, field_name, None)
118
+ return current
119
+
120
+
94
121
  class OnChangeMixin:
95
122
 
96
123
  _on_change_function = None
@@ -164,4 +191,4 @@ class OnChangeMixin:
164
191
  self._mqtt_client.disconnect()
165
192
  self._mqtt_client.loop_stop()
166
193
  self._mqtt_client = None
167
- self._on_change_function = None
194
+ self._on_change_function = None
simo/core/gateways.py CHANGED
@@ -14,6 +14,10 @@ from simo.core.loggers import get_gw_logger
14
14
 
15
15
 
16
16
  class BaseGatewayHandler(ABC):
17
+ # Whether core should auto-create a Gateway row for this handler
18
+ # at hub startup (via on_http_start). Opt-in to avoid noisy defaults
19
+ # and side effects for handlers that require credentials/hardware.
20
+ auto_create = False
17
21
 
18
22
  @property
19
23
  @abstractmethod
@@ -134,4 +138,3 @@ class BaseObjectCommandsGatewayHandler(BaseGatewayHandler):
134
138
  except Exception as e:
135
139
  self.logger.error(e, exc_info=True)
136
140
 
137
-
@@ -63,6 +63,17 @@ server {
63
63
  proxy_send_timeout 3600s;
64
64
  }
65
65
 
66
+ # MQTT over WebSockets (WSS)
67
+ location ^~ /mqtt/ {
68
+ proxy_http_version 1.1;
69
+ proxy_set_header Upgrade $http_upgrade;
70
+ proxy_set_header Connection "Upgrade";
71
+ proxy_set_header Host $host;
72
+ proxy_read_timeout 600s;
73
+ proxy_send_timeout 600s;
74
+ proxy_pass http://127.0.0.1:8083/;
75
+ }
76
+
66
77
 
67
78
  location / {
68
79
  include proxy_params;
@@ -78,6 +78,37 @@ killasgroup=true
78
78
  stopsignal=INT
79
79
 
80
80
 
81
+ [program:simo-app-mqtt]
82
+ directory={{ project_dir }}/hub/
83
+ command={{ venv_path }}/python manage.py run_app_mqtt_control
84
+ process_name=%(program_name)s
85
+ user=root
86
+ stdout_logfile=/var/log/simo/app_mqtt.log
87
+ stdout_logfile_maxbytes=1MB
88
+ stdout_logfile_backups=3
89
+ redirect_stderr=true
90
+ autostart=true
91
+ autorestart=true
92
+ stopwaitsecs=15
93
+ killasgroup=true
94
+ stopsignal=INT
95
+
96
+ [program:simo-app-mqtt-fanout]
97
+ directory={{ project_dir }}/hub/
98
+ command={{ venv_path }}/python manage.py run_app_mqtt_fanout
99
+ process_name=%(program_name)s
100
+ user=root
101
+ stdout_logfile=/var/log/simo/app_mqtt_fanout.log
102
+ stdout_logfile_maxbytes=1MB
103
+ stdout_logfile_backups=3
104
+ redirect_stderr=true
105
+ autostart=true
106
+ autorestart=true
107
+ stopwaitsecs=15
108
+ killasgroup=true
109
+ stopsignal=INT
110
+
111
+
81
112
  [program:simo-celery-beat]
82
113
  directory={{ project_dir }}/hub/
83
114
  command={{ venv_path }}/celery -A celeryc.celery_app beat -l info --pidfile="/var/run/celerybeat.pid"
@@ -10,6 +10,7 @@ from django.apps import apps
10
10
  from crontab import CronTab
11
11
  from django.conf import settings
12
12
  from django.template.loader import render_to_string
13
+ from django.db import transaction
13
14
 
14
15
 
15
16
  def prepare_mosquitto():
@@ -84,6 +85,70 @@ class Command(BaseCommand):
84
85
  from simo.core.tasks import maybe_update_to_latest
85
86
  maybe_update_to_latest.delay()
86
87
  update_auto_update()
88
+ # Auto-create Gateway rows for handlers that opted in
89
+ try:
90
+ from simo.core.models import Gateway
91
+ from simo.core.utils.type_constants import GATEWAYS_MAP
92
+ created_any = False
93
+ for uid, handler_cls in GATEWAYS_MAP.items():
94
+ if getattr(handler_cls, 'auto_create', False):
95
+ # Build default config from handler's config_form
96
+ config_defaults = {}
97
+ try:
98
+ form_cls = getattr(handler_cls, 'config_form', None)
99
+ if form_cls is not None:
100
+ tmp_instance = Gateway(type=uid)
101
+ form = form_cls(instance=tmp_instance)
102
+ for fname, field in getattr(form, 'fields', {}).items():
103
+ if fname in ('log',):
104
+ continue
105
+ init = getattr(field, 'initial', None)
106
+ if init is None:
107
+ continue
108
+ # Normalize potential Model/QuerySet values to pks
109
+ try:
110
+ import json as _json
111
+ def _norm(v):
112
+ try:
113
+ return _json.loads(_json.dumps(v))
114
+ except Exception:
115
+ pass
116
+ # Model instance
117
+ if hasattr(v, 'pk'):
118
+ return v.pk
119
+ # QuerySet or iterable of models
120
+ try:
121
+ from django.db.models.query import QuerySet as _QS
122
+ if isinstance(v, _QS):
123
+ return [obj.pk for obj in v]
124
+ except Exception:
125
+ pass
126
+ if isinstance(v, (list, tuple, set)):
127
+ return [_norm(x) for x in v]
128
+ return v
129
+ config_defaults[fname] = _norm(init)
130
+ except Exception:
131
+ # Best-effort; skip if cannot serialize
132
+ continue
133
+ except Exception:
134
+ # If we cannot introspect defaults, fall back to empty config
135
+ pass
136
+
137
+ obj, created = Gateway.objects.get_or_create(
138
+ type=uid, defaults={'config': config_defaults}
139
+ )
140
+ if created:
141
+ created_any = True
142
+ print(f"Auto-created gateway: {handler_cls.name} ({uid})")
143
+ try:
144
+ obj.start()
145
+ except Exception:
146
+ print(traceback.format_exc(), file=sys.stderr)
147
+ if created_any:
148
+ pass
149
+ except Exception:
150
+ # Do not fail startup on gateway auto-create issues
151
+ print(traceback.format_exc(), file=sys.stderr)
87
152
  for name, app in apps.app_configs.items():
88
153
  if name in (
89
154
  'auth', 'admin', 'contenttypes', 'sessions', 'messages',
@@ -96,4 +161,3 @@ class Command(BaseCommand):
96
161
  continue
97
162
  except:
98
163
  print(traceback.format_exc(), file=sys.stderr)
99
-
@@ -0,0 +1,60 @@
1
+ from django.core.management.base import BaseCommand
2
+ from django.utils import timezone
3
+
4
+ from simo.core.events import ObjectChangeEvent
5
+ from simo.core.models import Instance, Zone, Category, Component
6
+ from simo.users.models import InstanceUser
7
+
8
+
9
+ class Command(BaseCommand):
10
+ help = "Republish retained MQTT state for zones, categories, components, and instance users."
11
+
12
+ def add_arguments(self, parser):
13
+ parser.add_argument('--instance', type=int, help='Instance ID to republish (default: all)')
14
+
15
+ def handle(self, *args, **options):
16
+ instance_id = options.get('instance')
17
+ instances = Instance.objects.filter(is_active=True)
18
+ if instance_id:
19
+ instances = instances.filter(id=instance_id)
20
+
21
+ count = 0
22
+ for inst in instances:
23
+ # Zones
24
+ for zone in Zone.objects.filter(instance=inst):
25
+ ObjectChangeEvent(inst, zone, name=zone.name).publish()
26
+ count += 1
27
+
28
+ # Categories
29
+ for cat in Category.objects.filter(instance=inst):
30
+ ObjectChangeEvent(
31
+ inst, cat, name=cat.name, last_modified=cat.last_modified
32
+ ).publish()
33
+ count += 1
34
+
35
+ # Components
36
+ for comp in Component.objects.filter(zone__instance=inst):
37
+ data = {
38
+ 'value': comp.value,
39
+ 'last_change': comp.last_change,
40
+ 'arm_status': comp.arm_status,
41
+ 'battery_level': comp.battery_level,
42
+ 'alive': comp.alive,
43
+ 'meta': comp.meta,
44
+ }
45
+ ObjectChangeEvent(inst, comp, **data).publish()
46
+ count += 1
47
+
48
+ # Instance users (presence and phone charging)
49
+ for iu in InstanceUser.objects.filter(instance=inst, is_active=True):
50
+ ObjectChangeEvent(
51
+ inst,
52
+ iu,
53
+ at_home=iu.at_home,
54
+ last_seen=iu.last_seen,
55
+ phone_on_charge=iu.phone_on_charge,
56
+ ).publish()
57
+ count += 1
58
+
59
+ self.stdout.write(self.style.SUCCESS(f"Republished {count} retained messages."))
60
+
@@ -0,0 +1,129 @@
1
+ import json
2
+ import sys
3
+ import traceback
4
+ import paho.mqtt.client as mqtt
5
+ from django.core.management.base import BaseCommand
6
+ from django.conf import settings
7
+
8
+ from simo.users.models import User, InstanceUser, ComponentPermission
9
+ from simo.users.utils import introduce_user
10
+ from simo.core.models import Component
11
+
12
+
13
+ CONTROL_PREFIX = 'SIMO/user'
14
+
15
+
16
+ class Command(BaseCommand):
17
+ help = 'Run MQTT control bridge to execute component controller methods from app MQTT requests.'
18
+
19
+ def handle(self, *args, **options):
20
+ client = mqtt.Client()
21
+ client.username_pw_set('root', settings.SECRET_KEY)
22
+ client.on_connect = self.on_connect
23
+ client.on_message = self.on_message
24
+ client.connect(host=settings.MQTT_HOST, port=settings.MQTT_PORT)
25
+ try:
26
+ while True:
27
+ client.loop()
28
+ finally:
29
+ try:
30
+ client.disconnect()
31
+ except Exception:
32
+ pass
33
+
34
+ def on_connect(self, client, userdata, flags, rc):
35
+ # SIMO/user/+/control/#
36
+ client.subscribe(f'{CONTROL_PREFIX}/+/control/#')
37
+
38
+ def on_message(self, client, userdata, msg):
39
+ try:
40
+ parts = msg.topic.split('/')
41
+ # SIMO/user/<user-id>/control/<instance-uid>/Component/<component-id>
42
+ if len(parts) < 7 or parts[0] != 'SIMO' or parts[1] != 'user' or parts[3] != 'control':
43
+ return
44
+ user_id = int(parts[2])
45
+ instance_uid = parts[4]
46
+ if parts[5] != 'Component':
47
+ return
48
+ try:
49
+ component_id = int(parts[6])
50
+ except Exception:
51
+ return
52
+
53
+ # Resolve user and permission
54
+ user = User.objects.filter(id=user_id).first()
55
+ if not user or not user.is_active:
56
+ return
57
+ if not user.is_master:
58
+ # Must be active on instance
59
+ if not InstanceUser.objects.filter(
60
+ user=user, instance__uid=instance_uid, is_active=True
61
+ ).exists():
62
+ return
63
+ # Must have write permission on the component
64
+ has_write = ComponentPermission.objects.filter(
65
+ role__in=user.roles.all(),
66
+ component_id=component_id,
67
+ component__zone__instance__uid=instance_uid,
68
+ write=True,
69
+ ).exists()
70
+ if not has_write:
71
+ return
72
+
73
+ # Execute controller method
74
+ component = Component.objects.filter(
75
+ id=component_id, zone__instance__uid=instance_uid
76
+ ).first()
77
+ if not component:
78
+ return
79
+
80
+ introduce_user(user)
81
+ payload = json.loads(msg.payload or '{}')
82
+ request_id = payload.get('request_id')
83
+ sub_id = payload.get('subcomponent_id')
84
+ method = payload.get('method')
85
+ args = payload.get('args', [])
86
+ kwargs = payload.get('kwargs', {})
87
+ if method in (None, 'id', 'secret') or str(method).startswith('_'):
88
+ return
89
+
90
+ # Choose target component (main or subcomponent)
91
+ target = component
92
+ if sub_id:
93
+ try:
94
+ target = component.slaves.get(pk=sub_id)
95
+ except Exception:
96
+ return
97
+
98
+ # Prepare controller and call
99
+ target.prepare_controller()
100
+ if not hasattr(target, method):
101
+ self.respond(client, user_id, request_id, ok=False, error=f'Method {method} not found')
102
+ return
103
+ call = getattr(target, method)
104
+ try:
105
+ if isinstance(args, list) and isinstance(kwargs, dict):
106
+ result = call(*args, **kwargs)
107
+ elif isinstance(args, list):
108
+ result = call(*args)
109
+ elif isinstance(kwargs, dict):
110
+ result = call(**kwargs)
111
+ else:
112
+ result = call()
113
+ self.respond(client, user_id, request_id, ok=True, result=result)
114
+ except Exception:
115
+ self.respond(client, user_id, request_id, ok=False, error=''.join(traceback.format_exception(*sys.exc_info())))
116
+ except Exception:
117
+ # Never crash the consumer
118
+ pass
119
+
120
+ def respond(self, client, user_id, request_id, ok=True, result=None, error=None):
121
+ if not request_id:
122
+ return
123
+ topic = f'{CONTROL_PREFIX}/{user_id}/control-resp/{request_id}'
124
+ payload = {'ok': ok}
125
+ if ok:
126
+ payload['result'] = result
127
+ else:
128
+ payload['error'] = error
129
+ client.publish(topic, json.dumps(payload), qos=0, retain=False)
@@ -0,0 +1,96 @@
1
+ import json
2
+ import sys
3
+ import traceback
4
+ import paho.mqtt.client as mqtt
5
+ from django.core.management.base import BaseCommand
6
+ from django.conf import settings
7
+ from simo.core.events import get_event_obj
8
+ from simo.core.models import Component, Zone, Category
9
+ from simo.users.models import User, InstanceUser, ComponentPermission
10
+
11
+
12
+ OBJ_STATE_PREFIX = 'SIMO/obj-state'
13
+ FEED_PREFIX = 'SIMO/user'
14
+
15
+
16
+ class Command(BaseCommand):
17
+ help = 'Authorizing fanout for app feeds: replicate internal obj-state to per-user feed topics.'
18
+
19
+ def handle(self, *args, **options):
20
+ self.client = mqtt.Client()
21
+ self.client.username_pw_set('root', settings.SECRET_KEY)
22
+ self.client.on_connect = self.on_connect
23
+ self.client.on_message = self.on_message
24
+ self.client.connect(host=settings.MQTT_HOST, port=settings.MQTT_PORT)
25
+ try:
26
+ while True:
27
+ self.client.loop()
28
+ finally:
29
+ try:
30
+ self.client.disconnect()
31
+ except Exception:
32
+ pass
33
+
34
+ def on_connect(self, client, userdata, flags, rc):
35
+ client.subscribe(f'{OBJ_STATE_PREFIX}/#')
36
+
37
+ def on_message(self, client, userdata, msg):
38
+ try:
39
+ topic_parts = msg.topic.split('/')
40
+ # SIMO/obj-state/<instance-uid>/<Model>/<id>
41
+ if len(topic_parts) < 5 or topic_parts[0] != 'SIMO' or topic_parts[1] != 'obj-state':
42
+ return
43
+ instance_uid = topic_parts[2]
44
+ model_name = topic_parts[3]
45
+ obj_id = topic_parts[4]
46
+
47
+ # Only forward instance-scoped objects that the app cares about
48
+ payload = json.loads(msg.payload or '{}')
49
+
50
+ # Resolve object if needed (mainly for Components to do permission checks)
51
+ target_obj = None
52
+ if model_name == 'Component':
53
+ target_obj = get_event_obj(payload, model_class=Component)
54
+ if not target_obj:
55
+ return
56
+ elif model_name == 'Zone':
57
+ target_obj = get_event_obj(payload, model_class=Zone)
58
+ elif model_name == 'Category':
59
+ target_obj = get_event_obj(payload, model_class=Category)
60
+ elif model_name == 'InstanceUser':
61
+ # presence updates; no need to resolve explicitly
62
+ pass
63
+ else:
64
+ # Ignore other objects for feed
65
+ return
66
+
67
+ publish_to_users = set([
68
+ m.id for m in User.objects.filter(is_master=True) if m.is_active
69
+ ])
70
+ for iu in InstanceUser.objects.filter(
71
+ instance__uid=instance_uid, is_active=True
72
+ ).select_related('user', 'role'):
73
+ if iu.user.is_master:
74
+ publish_to_users.add(iu.user.id)
75
+ continue
76
+ if iu.role.is_superuser:
77
+ publish_to_users.add(iu.user.id)
78
+ continue
79
+ if model_name != 'Component':
80
+ publish_to_users.add(iu.user.id)
81
+ continue
82
+ if ComponentPermission.objects.filter(
83
+ role=iu.role,
84
+ component_id=target_obj.id,
85
+ component__zone__instance__uid=instance_uid,
86
+ read=True,
87
+ ).exists():
88
+ publish_to_users.add(iu.user.id)
89
+ continue
90
+
91
+ for user_id in publish_to_users:
92
+ feed_topic = f'{FEED_PREFIX}/{user_id}/feed/{instance_uid}/{model_name}/{obj_id}'
93
+ client.publish(feed_topic, msg.payload, qos=0, retain=True)
94
+ except Exception:
95
+ # Never crash the consumer
96
+ print('Fanout error:', ''.join(traceback.format_exception(*sys.exc_info())), file=sys.stderr)
@@ -136,26 +136,50 @@ def post_save_actions_dispatcher(sender, instance, created, **kwargs):
136
136
  action_type='management_event'
137
137
  )
138
138
 
139
+ # Announce Zone/Category changes over MQTT for mobile live updates
140
+ from .events import ObjectChangeEvent, dirty_fields_to_current_values
141
+ dirty_fields_prev = instance.get_dirty_fields()
142
+
143
+ def post_update():
144
+ if not dirty_fields_prev:
145
+ return
146
+
147
+ data = {}
148
+ # Provide minimal fields clients can use without re-fetching
149
+ if isinstance(instance, Zone):
150
+ data['name'] = instance.name
151
+ elif isinstance(instance, Category):
152
+ data['name'] = instance.name
153
+ data['last_modified'] = instance.last_modified
154
+
155
+ ObjectChangeEvent(
156
+ instance.instance, instance,
157
+ dirty_fields=dirty_fields_to_current_values(instance, dirty_fields_prev),
158
+ **data
159
+ ).publish()
160
+
161
+ transaction.on_commit(post_update)
162
+
139
163
 
140
164
  @receiver(post_save, sender=Component)
141
165
  @receiver(post_save, sender=Gateway)
142
166
  def post_save_change_events(sender, instance, created, **kwargs):
143
167
  target = instance
144
- from .events import ObjectChangeEvent
145
- dirty_fields = target.get_dirty_fields()
168
+ from .events import ObjectChangeEvent, dirty_fields_to_current_values
169
+ dirty_fields_prev = target.get_dirty_fields()
146
170
  for ignore_field in (
147
171
  'change_init_by', 'change_init_date', 'change_init_to', 'last_update'
148
172
  ):
149
- dirty_fields.pop(ignore_field, None)
173
+ dirty_fields_prev.pop(ignore_field, None)
150
174
 
151
175
  def post_update():
152
- if not dirty_fields:
176
+ if not dirty_fields_prev:
153
177
  return
154
178
 
155
179
  if type(target) == Gateway:
156
180
  ObjectChangeEvent(
157
181
  None, target,
158
- dirty_fields=dirty_fields,
182
+ dirty_fields=dirty_fields_to_current_values(target, dirty_fields_prev),
159
183
  ).publish()
160
184
  elif type(target) == Component:
161
185
  data = {}
@@ -166,7 +190,7 @@ def post_save_change_events(sender, instance, created, **kwargs):
166
190
  data[field_name] = getattr(target, field_name, None)
167
191
  ObjectChangeEvent(
168
192
  target.zone.instance, target,
169
- dirty_fields=dirty_fields,
193
+ dirty_fields=dirty_fields_to_current_values(target, dirty_fields_prev),
170
194
  actor=getattr(target, 'change_actor', None),
171
195
  **data
172
196
  ).publish()
@@ -217,4 +241,4 @@ def delete_file_itself(sender, instance, *args, **kwargs):
217
241
  try:
218
242
  os.remove(instance.file.path)
219
243
  except:
220
- pass
244
+ pass
simo/core/tasks.py CHANGED
@@ -441,39 +441,7 @@ def restart_postgresql():
441
441
  proc.communicate()
442
442
 
443
443
 
444
- @celery_app.task
445
- def low_battery_notifications():
446
- from simo.notifications.utils import notify_users
447
- from simo.automation.helpers import be_or_not_to_be
448
- for instance in Instance.objects.filter(is_active=True):
449
- timezone.activate(instance.timezone)
450
- hour = timezone.localtime().hour
451
- if hour < 7:
452
- continue
453
- if hour > 21:
454
- continue
455
-
456
- introduce_instance(instance)
457
- for comp in Component.objects.filter(
458
- zone__instance=instance,
459
- battery_level__isnull=False, battery_level__lt=20
460
- ):
461
- last_warning = comp.meta.get('last_battery_warning', 0)
462
- notify = be_or_not_to_be(12 * 60 * 60, 72 * 60 * 60, last_warning)
463
- if not notify:
464
- continue
465
444
 
466
- iusers = comp.zone.instance.instance_users.filter(
467
- is_active=True, role__is_owner=True
468
- )
469
- if iusers:
470
- notify_users(
471
- 'warning',
472
- f"Low battery ({comp.battery_level}%) on {comp}",
473
- component=comp, instance_users=iusers
474
- )
475
- comp.meta['last_battery_warning'] = time.time()
476
- comp.save()
477
445
 
478
446
 
479
447
  @celery_app.task
@@ -516,4 +484,3 @@ def setup_periodic_tasks(sender, **kwargs):
516
484
  sender.add_periodic_task(60 * 60, maybe_update_to_latest.s())
517
485
  sender.add_periodic_task(60, drop_fingerprints_learn.s())
518
486
  sender.add_periodic_task(60 * 60 * 24, restart_postgresql.s())
519
- sender.add_periodic_task(60 * 60, low_battery_notifications.s())