simo 2.5.42__py3-none-any.whl → 2.6.2__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.

Files changed (58) hide show
  1. simo/__pycache__/settings.cpython-38.pyc +0 -0
  2. simo/automation/__pycache__/__init__.cpython-38.pyc +0 -0
  3. simo/automation/__pycache__/app_widgets.cpython-38.pyc +0 -0
  4. simo/automation/__pycache__/controllers.cpython-38.pyc +0 -0
  5. simo/automation/__pycache__/forms.cpython-38.pyc +0 -0
  6. simo/automation/__pycache__/gateways.cpython-38.pyc +0 -0
  7. simo/{generic/scripting → automation}/__pycache__/helpers.cpython-38.pyc +0 -0
  8. simo/{generic/scripting → automation}/__pycache__/serializers.cpython-38.pyc +0 -0
  9. simo/{generic/scripting/__pycache__/__init__.cpython-38.pyc → automation/__pycache__/state.cpython-38.pyc} +0 -0
  10. simo/automation/app_widgets.py +8 -0
  11. simo/automation/controllers.py +273 -0
  12. simo/automation/forms.py +290 -0
  13. simo/automation/gateways.py +257 -0
  14. simo/automation/migrations/0001_initial.py +39 -0
  15. simo/automation/migrations/0002_update_helpers_in_scripts.py +29 -0
  16. simo/automation/migrations/__init__.py +0 -0
  17. simo/automation/migrations/__pycache__/0001_initial.cpython-38.pyc +0 -0
  18. simo/automation/migrations/__pycache__/0002_update_helpers_in_scripts.cpython-38.pyc +0 -0
  19. simo/automation/migrations/__pycache__/__init__.cpython-38.pyc +0 -0
  20. simo/automation/templates/automations/auto_away.py +55 -0
  21. simo/automation/templates/automations/auto_state_script.py +31 -0
  22. simo/{core/templates/core/auto_night_day_script.py → automation/templates/automations/phones_sleep_script.py} +25 -13
  23. simo/core/__pycache__/admin.cpython-38.pyc +0 -0
  24. simo/core/__pycache__/filters.cpython-38.pyc +0 -0
  25. simo/core/__pycache__/models.cpython-38.pyc +0 -0
  26. simo/core/__pycache__/signal_receivers.cpython-38.pyc +0 -0
  27. simo/core/admin.py +7 -4
  28. simo/core/filters.py +61 -0
  29. simo/core/signal_receivers.py +50 -17
  30. simo/core/utils/__pycache__/type_constants.cpython-38.pyc +0 -0
  31. simo/core/utils/type_constants.py +1 -1
  32. simo/fleet/__pycache__/api.cpython-38.pyc +0 -0
  33. simo/fleet/__pycache__/serializers.cpython-38.pyc +0 -0
  34. simo/fleet/api.py +6 -0
  35. simo/fleet/serializers.py +9 -1
  36. simo/generic/__pycache__/app_widgets.cpython-38.pyc +0 -0
  37. simo/generic/__pycache__/controllers.cpython-38.pyc +0 -0
  38. simo/generic/__pycache__/forms.cpython-38.pyc +0 -0
  39. simo/generic/__pycache__/gateways.cpython-38.pyc +0 -0
  40. simo/generic/app_widgets.py +0 -6
  41. simo/generic/controllers.py +4 -262
  42. simo/generic/forms.py +2 -280
  43. simo/generic/gateways.py +4 -193
  44. simo/settings.py +1 -0
  45. simo/users/__pycache__/api.cpython-38.pyc +0 -0
  46. simo/users/api.py +1 -2
  47. {simo-2.5.42.dist-info → simo-2.6.2.dist-info}/METADATA +1 -1
  48. {simo-2.5.42.dist-info → simo-2.6.2.dist-info}/RECORD +57 -41
  49. simo/core/templates/core/auto_state_script.py +0 -78
  50. /simo/{generic/scripting/example.py → automation/__init__.py} +0 -0
  51. /simo/{generic/scripting → automation}/helpers.py +0 -0
  52. /simo/{generic/scripting → automation}/serializers.py +0 -0
  53. /simo/{generic/scripting/__init__.py → automation/state.py} +0 -0
  54. /simo/{generic → automation}/templates/admin/controller_widgets/script.html +0 -0
  55. {simo-2.5.42.dist-info → simo-2.6.2.dist-info}/LICENSE.md +0 -0
  56. {simo-2.5.42.dist-info → simo-2.6.2.dist-info}/WHEEL +0 -0
  57. {simo-2.5.42.dist-info → simo-2.6.2.dist-info}/entry_points.txt +0 -0
  58. {simo-2.5.42.dist-info → simo-2.6.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,257 @@
1
+ import sys
2
+ import logging
3
+ import pytz
4
+ import json
5
+ import time
6
+ import multiprocessing
7
+ import threading
8
+ import traceback
9
+ from django.conf import settings
10
+ from django.utils import timezone
11
+ from django.db import connection as db_connection
12
+ from django.db.models import Q
13
+ import paho.mqtt.client as mqtt
14
+ from simo.core.models import Component
15
+ from simo.core.middleware import introduce_instance, drop_current_instance
16
+ from simo.core.gateways import BaseObjectCommandsGatewayHandler
17
+ from simo.core.forms import BaseGatewayForm
18
+ from simo.core.utils.logs import StreamToLogger
19
+ from simo.core.events import GatewayObjectCommand, get_event_obj
20
+ from simo.core.loggers import get_gw_logger, get_component_logger
21
+
22
+
23
+ class ScriptRunHandler(multiprocessing.Process):
24
+ '''
25
+ Threading offers better overall stability, but we use
26
+ multiprocessing for Scripts so that they are better isolated and
27
+ we are able to kill them whenever we need.
28
+ '''
29
+ component = None
30
+ logger = None
31
+
32
+ def __init__(self, component_id, *args, **kwargs):
33
+ super().__init__(*args, **kwargs)
34
+ self.component_id = component_id
35
+
36
+ def run(self):
37
+ db_connection.connect()
38
+ self.component = Component.objects.get(id=self.component_id)
39
+ try:
40
+ tz = pytz.timezone(self.component.zone.instance.timezone)
41
+ except:
42
+ tz = pytz.timezone('UTC')
43
+ timezone.activate(tz)
44
+ introduce_instance(self.component.zone.instance)
45
+ self.logger = get_component_logger(self.component)
46
+ sys.stdout = StreamToLogger(self.logger, logging.INFO)
47
+ sys.stderr = StreamToLogger(self.logger, logging.ERROR)
48
+ self.component.set('running')
49
+
50
+ if hasattr(self.component.controller, '_run'):
51
+ def run_code():
52
+ self.component.controller._run()
53
+ else:
54
+ code = self.component.config.get('code')
55
+ def run_code():
56
+ start = time.time()
57
+ exec(code, globals())
58
+ if 'class Automation:' in code and time.time() - start < 1:
59
+ Automation().run()
60
+
61
+ if not code:
62
+ self.component.value = 'finished'
63
+ self.component.save(update_fields=['value'])
64
+ return
65
+ print("------START-------")
66
+ try:
67
+ run_code()
68
+ except:
69
+ print("------ERROR------")
70
+ self.component.set('error')
71
+ raise
72
+ else:
73
+ print("------FINISH-----")
74
+ self.component.set('finished')
75
+ return
76
+
77
+
78
+ class AutomationsGatewayHandler(BaseObjectCommandsGatewayHandler):
79
+ name = "Automation"
80
+ config_form = BaseGatewayForm
81
+
82
+ running_scripts = {}
83
+ periodic_tasks = (
84
+ ('watch_scripts', 10),
85
+ )
86
+
87
+ terminating_scripts = set()
88
+
89
+
90
+ def watch_scripts(self):
91
+ drop_current_instance()
92
+ # observe running scripts and drop the ones that are no longer alive
93
+ dead_scripts = False
94
+ for id, process in list(self.running_scripts.items()):
95
+ comp = Component.objects.filter(id=id).first()
96
+ if process.is_alive():
97
+ if not comp and id not in self.terminating_scripts:
98
+ # script is deleted, or instance deactivated
99
+ process.kill()
100
+ continue
101
+ else:
102
+ if id not in self.terminating_scripts:
103
+ dead_scripts = True
104
+ if comp:
105
+ logger = get_component_logger(comp)
106
+ logger.log(logging.INFO, "-------DEAD!-------")
107
+ self.stop_script(comp, 'error')
108
+
109
+ if dead_scripts:
110
+ # give 10s air before we wake these dead scripts up!
111
+ return
112
+
113
+ from .controllers import Script
114
+ for script in Component.objects.filter(
115
+ controller_uid=Script.uid,
116
+ config__keep_alive=True
117
+ ).exclude(value__in=('running', 'stopped', 'finished')):
118
+ self.start_script(script)
119
+
120
+ def run(self, exit):
121
+ drop_current_instance()
122
+ self.exit = exit
123
+ self.logger = get_gw_logger(self.gateway_instance.id)
124
+ for task, period in self.periodic_tasks:
125
+ threading.Thread(
126
+ target=self._run_periodic_task, args=(exit, task, period), daemon=True
127
+ ).start()
128
+
129
+ from .controllers import Script
130
+
131
+ mqtt_client = mqtt.Client()
132
+ mqtt_client.username_pw_set('root', settings.SECRET_KEY)
133
+ mqtt_client.on_connect = self.on_mqtt_connect
134
+ mqtt_client.on_message = self.on_mqtt_message
135
+ mqtt_client.connect(host=settings.MQTT_HOST, port=settings.MQTT_PORT)
136
+
137
+ # We presume that this is the only running gateway, therefore
138
+ # if there are any running scripts, that is not true.
139
+ for component in Component.objects.filter(
140
+ controller_uid=Script.uid, value='running'
141
+ ):
142
+ component.value = 'error'
143
+ component.save()
144
+
145
+ # Start scripts that are designed to be autostarted
146
+ # as well as those that are designed to be kept alive, but
147
+ # got terminated unexpectedly
148
+ for script in Component.objects.filter(
149
+ base_type='script',
150
+ ).filter(
151
+ Q(config__autostart=True) |
152
+ Q(value='error', config__keep_alive=True)
153
+ ).distinct():
154
+ self.start_script(script)
155
+
156
+ print("GATEWAY STARTED!")
157
+ while not exit.is_set():
158
+ mqtt_client.loop()
159
+ mqtt_client.disconnect()
160
+
161
+ script_ids = [id for id in self.running_scripts.keys()]
162
+ for id in script_ids:
163
+ self.stop_script(
164
+ Component.objects.get(id=id), 'error'
165
+ )
166
+
167
+ time.sleep(0.5)
168
+ while len(self.running_scripts.keys()):
169
+ print("Still running scripts: ", self.running_scripts.keys())
170
+ time.sleep(0.5)
171
+
172
+ def on_mqtt_connect(self, mqtt_client, userdata, flags, rc):
173
+ command = GatewayObjectCommand(self.gateway_instance)
174
+ mqtt_client.subscribe(command.get_topic())
175
+
176
+ def on_mqtt_message(self, client, userdata, msg):
177
+ print("Mqtt message: ", msg.payload)
178
+ from .controllers import Script
179
+ payload = json.loads(msg.payload)
180
+ drop_current_instance()
181
+ component = get_event_obj(payload, Component)
182
+ if not component:
183
+ return
184
+ introduce_instance(component.zone.instance)
185
+ if not isinstance(component.controller, Script):
186
+ return
187
+
188
+ if payload.get('set_val') == 'start':
189
+ self.start_script(component)
190
+ elif payload.get('set_val') == 'stop':
191
+ self.stop_script(component)
192
+
193
+
194
+ def start_script(self, component):
195
+ print("START SCRIPT %s" % str(component))
196
+ if component.id in self.running_scripts:
197
+ if component.id not in self.terminating_scripts:
198
+ if component.value != 'running':
199
+ component.value = 'running'
200
+ component.save()
201
+ return
202
+ else:
203
+ good_to_go = False
204
+ for i in range(12): # wait for 3s
205
+ time.sleep(0.2)
206
+ component.refresh_from_db()
207
+ if component.id not in self.running_scripts:
208
+ good_to_go = True
209
+ break
210
+ if not good_to_go:
211
+ return self.stop_script(component, 'error')
212
+
213
+ self.running_scripts[component.id] = ScriptRunHandler(
214
+ component.id, daemon=True
215
+ )
216
+ self.running_scripts[component.id].start()
217
+
218
+ def stop_script(self, component, stop_status='stopped'):
219
+ self.terminating_scripts.add(component.id)
220
+ if component.id not in self.running_scripts:
221
+ if component.value == 'running':
222
+ component.value = stop_status
223
+ component.save(update_fields=['value'])
224
+ return
225
+
226
+ tz = pytz.timezone(component.zone.instance.timezone)
227
+ timezone.activate(tz)
228
+ logger = get_component_logger(component)
229
+ if stop_status == 'error':
230
+ logger.log(logging.INFO, "-------GATEWAY STOP-------")
231
+ else:
232
+ logger.log(logging.INFO, "-------STOP-------")
233
+ self.running_scripts[component.id].terminate()
234
+
235
+ def kill():
236
+ start = time.time()
237
+ terminated = False
238
+ while start > time.time() - 2:
239
+ if not self.running_scripts[component.id].is_alive():
240
+ terminated = True
241
+ break
242
+ time.sleep(0.1)
243
+ if not terminated:
244
+ if stop_status == 'error':
245
+ logger.log(logging.INFO, "-------GATEWAY KILL-------")
246
+ else:
247
+ logger.log(logging.INFO, "-------KILL!-------")
248
+ self.running_scripts[component.id].kill()
249
+
250
+ component.set(stop_status)
251
+ self.terminating_scripts.remove(component.id)
252
+ # making sure it's fully killed along with it's child processes
253
+ self.running_scripts[component.id].kill()
254
+ self.running_scripts.pop(component.id, None)
255
+ logger.handlers = []
256
+
257
+ threading.Thread(target=kill, daemon=True).start()
@@ -0,0 +1,39 @@
1
+ # Generated by Django 4.2.10 on 2024-11-27 07:40
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ def forwards_func(apps, schema_editor):
7
+ Component = apps.get_model("core", "Component")
8
+ Gateway = apps.get_model('core', "Gateway")
9
+
10
+ automation, new = Gateway.objects.get_or_create(
11
+ type='simo.automation.gateways.AutomationsGatewayHandler'
12
+ )
13
+
14
+ for script in Component.objects.filter(
15
+ controller_uid__in=(
16
+ 'simo.generic.controllers.PresenceLighting',
17
+ 'simo.generic.controllers.Script',
18
+ )
19
+ ):
20
+ script.controller_uid = script.controller_uid.replace(
21
+ 'simo.generic', 'simo.automation'
22
+ )
23
+ script.gateway = automation
24
+ script.save()
25
+
26
+
27
+ def reverse_func(apps, schema_editor):
28
+ pass
29
+
30
+
31
+ class Migration(migrations.Migration):
32
+
33
+ dependencies = [
34
+ ('generic', '0002_auto_20241126_0726'),
35
+ ]
36
+
37
+ operations = [
38
+ migrations.RunPython(forwards_func, reverse_func, elidable=True),
39
+ ]
@@ -0,0 +1,29 @@
1
+ # Generated by Django 4.2.10 on 2024-11-27 08:23
2
+
3
+ from django.db import migrations
4
+
5
+ def forwards_func(apps, schema_editor):
6
+ Component = apps.get_model("core", "Component")
7
+
8
+ for script in Component.objects.filter(
9
+ controller_uid='simo.automation.controllers.Script'
10
+ ):
11
+ script.config['code'] = script.config['code'].replace(
12
+ 'simo.generic.scripting.helpers', 'simo.automation.helpers'
13
+ )
14
+ script.save()
15
+
16
+
17
+ def reverse_func(apps, schema_editor):
18
+ pass
19
+
20
+
21
+ class Migration(migrations.Migration):
22
+
23
+ dependencies = [
24
+ ('automation', '0001_initial'),
25
+ ]
26
+
27
+ operations = [
28
+ migrations.RunPython(forwards_func, reverse_func, elidable=True),
29
+ ]
File without changes
@@ -0,0 +1,55 @@
1
+ import time
2
+ from django.utils import timezone
3
+ from simo.core.middleware import get_current_instance
4
+ from simo.core.models import Component
5
+ from simo.users.models import InstanceUser
6
+ from simo.automation.helpers import (
7
+ get_day_evening_night_morning, LocalSun
8
+ )
9
+
10
+
11
+ class Automation:
12
+ STATE_COMP_ID = {{ state_comp_id }}
13
+ INACTIVITY_MINUTES = 30
14
+
15
+ def __init__(self):
16
+ self.instance = get_current_instance()
17
+ self.state = Component.objects.get(id=self.STATE_COMP_ID)
18
+ self.sensors_on_watch = set()
19
+ self.last_sensor_action = time.time()
20
+ self.sun = LocalSun(self.instance.location)
21
+
22
+ def sensor_change(self, sensor=None):
23
+ self.last_sensor_action = time.time()
24
+
25
+ def check_away(self):
26
+ if InstanceUser.objects.filter(
27
+ is_active=True, at_home=True
28
+ ).count():
29
+ return False
30
+
31
+ return (
32
+ time.time() - self.last_sensor_action
33
+ ) // 60 >= self.INACTIVITY_MINUTES
34
+
35
+ def run(self):
36
+ while True:
37
+ for sensor in Component.objects.filter(
38
+ base_type='binary-sensor',
39
+ alarm_category='security'
40
+ ):
41
+ if sensor.id not in self.sensors_on_watch:
42
+ sensor.on_change(self.sensor_change)
43
+ self.sensors_on_watch.add(sensor.id)
44
+
45
+ self.state.refresh_from_db()
46
+ if self.check_away():
47
+ if self.state.value != 'away':
48
+ print("AWAY!")
49
+ self.state.send('away')
50
+ else:
51
+ if self.state.value == 'away':
52
+ new_state = get_day_evening_night_morning(
53
+ self.sun, timezone.localtime()
54
+ )
55
+ print(f"{new_state.upper()}!")
@@ -0,0 +1,31 @@
1
+ import time
2
+ import random
3
+ from django.utils import timezone
4
+ from simo.core.middleware import get_current_instance
5
+ from simo.core.models import Component
6
+ from simo.automation.helpers import (
7
+ LocalSun, get_day_evening_night_morning
8
+ )
9
+
10
+
11
+ class Automation:
12
+ STATE_COMPONENT_ID = {{ state_comp_id }}
13
+
14
+ def __init__(self):
15
+ self.state = Component.objects.get(id=self.STATE_COMPONENT_ID)
16
+ self.sun = LocalSun(get_current_instance().location)
17
+
18
+ def run(self):
19
+ while True:
20
+ self.state.refresh_from_db()
21
+ if self.state.value in ('day', 'night', 'evening', 'morning'):
22
+
23
+ current_state = get_day_evening_night_morning(
24
+ self.sun, timezone.localtime()
25
+ )
26
+ if current_state != self.state.value:
27
+ print(f"New state - {current_state}")
28
+ self.state.send(current_state)
29
+
30
+ # randomize script check times
31
+ time.sleep(random.randint(20, 40))
@@ -4,7 +4,9 @@ from django.utils import timezone
4
4
  from simo.core.middleware import get_current_instance
5
5
  from simo.core.models import Component
6
6
  from simo.users.models import InstanceUser
7
- from simo.generic.scripting.helpers import LocalSun
7
+ from simo.automation.helpers import (
8
+ LocalSun, get_day_evening_night_morning
9
+ )
8
10
 
9
11
 
10
12
  class Automation:
@@ -14,23 +16,26 @@ class Automation:
14
16
  self.instance = get_current_instance()
15
17
  self.state = Component.objects.get(id=self.STATE_COMPONENT_ID)
16
18
  self.sun = LocalSun(self.instance.location)
17
- self.night_is_on = False
19
+ self.sleep_is_on = False
18
20
 
19
21
  def check_owner_phones(self, state, instance_users, datetime):
20
- if not self.night_is_on:
21
- if not (datetime.hour >= 22 or datetime.hour < 6):
22
+ if not self.sleep_is_on:
23
+ if not (datetime.hour >= 21 or datetime.hour < 6):
22
24
  return
23
25
 
24
26
  for iuser in instance_users:
27
+ # ignoring inactive and non owner users
28
+ if not iuser.is_active or not iuser.role.is_owner:
29
+ continue
25
30
  # skipping users that are not at home
26
31
  if not iuser.at_home:
27
32
  continue
28
33
  if not iuser.phone_on_charge:
29
34
  # at least one user's phone is not yet on charge
30
35
  return
31
- self.night_is_on = True
32
- print("Night!")
33
- return 'night'
36
+ self.sleep_is_on = True
37
+ print("Let's turn on the sleep mode!")
38
+ return 'sleep'
34
39
  else:
35
40
  if datetime.hour >= 22 or datetime.hour < 6:
36
41
  return
@@ -38,6 +43,9 @@ class Automation:
38
43
  # at home, none of them have their phones on charge
39
44
  # and current state is still night
40
45
  for iuser in instance_users:
46
+ # ignoring inactive and non owner users
47
+ if not iuser.is_active or not iuser.role.is_owner:
48
+ continue
41
49
  # skipping users that are not at home
42
50
  if not iuser.at_home:
43
51
  continue
@@ -45,16 +53,20 @@ class Automation:
45
53
  # at least one user's phone is still on charge
46
54
  return
47
55
 
48
- self.night_is_on = False
49
- if not self.night_is_on and state.value == 'night':
50
- print("Day has come!")
51
- return 'day'
56
+ self.sleep_is_on = False
57
+
58
+ if not self.sleep_is_on and state.value == 'sleep':
59
+ new_state = get_day_evening_night_morning(
60
+ self.sun, timezone.localtime()
61
+ )
62
+ print(f"Switch state back to {new_state}!")
63
+ return new_state
52
64
 
53
65
  def run(self):
54
66
  while True:
55
67
  instance_users = InstanceUser.objects.filter(
56
68
  is_active=True, role__is_owner=True
57
- )
69
+ ).prefetch_related('role')
58
70
  self.state.refresh_from_db()
59
71
  new_state = self.check_owner_phones(
60
72
  self.state, instance_users, timezone.localtime()
@@ -62,5 +74,5 @@ class Automation:
62
74
  if new_state:
63
75
  self.state.send(new_state)
64
76
 
65
- # randomize script load
77
+ # randomize script check times
66
78
  time.sleep(random.randint(20, 40))
Binary file
Binary file
simo/core/admin.py CHANGED
@@ -22,7 +22,7 @@ from .forms import (
22
22
  CompTypeSelectForm,
23
23
  BaseComponentForm
24
24
  )
25
- from .filters import ZonesFilter
25
+ from .filters import ZonesFilter, AvailableChoicesFilter
26
26
  from .widgets import AdminImageWidget
27
27
  from simo.conf import dynamic_settings
28
28
 
@@ -255,7 +255,8 @@ class ComponentPermissionInline(admin.TabularInline):
255
255
  class ComponentAdmin(EasyObjectsDeleteMixin, admin.ModelAdmin):
256
256
  form = BaseComponentForm
257
257
  list_display = (
258
- 'id', 'name_display', 'value_display', 'base_type', 'alive', 'battery_level',
258
+ 'id', 'name_display', 'value_display', 'base_type', 'controller_uid',
259
+ 'alive', 'battery_level',
259
260
  'alarm_category', 'show_in_app',
260
261
  )
261
262
  readonly_fields = (
@@ -264,8 +265,10 @@ class ComponentAdmin(EasyObjectsDeleteMixin, admin.ModelAdmin):
264
265
  'control', 'value', 'arm_status', 'history', 'meta'
265
266
  )
266
267
  list_filter = (
267
- 'gateway', 'base_type', ('zone', ZonesFilter), 'category', 'alive',
268
- 'alarm_category', 'arm_status'
268
+ 'gateway', 'base_type', ('zone', ZonesFilter), 'category',
269
+ #'controller_uid',
270
+ ('controller_uid', AvailableChoicesFilter),
271
+ 'alive', 'alarm_category', 'arm_status',
269
272
  )
270
273
 
271
274
  search_fields = 'id', 'name', 'value', 'config', 'meta', 'notes'
simo/core/filters.py CHANGED
@@ -1,4 +1,10 @@
1
1
  from django.contrib import admin
2
+ from django.contrib.admin.utils import (
3
+ get_model_from_relation,
4
+ prepare_lookup_value,
5
+ reverse_field_path,
6
+ )
7
+ from django.utils.translation import gettext_lazy as _
2
8
 
3
9
 
4
10
  class ZonesFilter(admin.RelatedFieldListFilter):
@@ -10,3 +16,58 @@ class ZonesFilter(admin.RelatedFieldListFilter):
10
16
  include_blank=False, ordering=ordering,
11
17
  limit_choices_to=limit_to
12
18
  )
19
+
20
+
21
+ class AvailableChoicesFilter(admin.ChoicesFieldListFilter):
22
+ """
23
+ presents as choices to filter only those choices that are present in a queryset
24
+ """
25
+
26
+ def __init__(self, field, request, params, model, model_admin, field_path):
27
+ super().__init__(field, request, params, model, model_admin, field_path)
28
+ parent_model, reverse_path = reverse_field_path(model, field_path)
29
+ # Obey parent ModelAdmin queryset when deciding which options to show
30
+ if model == parent_model:
31
+ queryset = model_admin.get_queryset(request)
32
+ else:
33
+ queryset = parent_model._default_manager.all()
34
+ self.lookup_choices = (
35
+ queryset.distinct().order_by(field.name).values_list(
36
+ field.name, flat=True
37
+ )
38
+ )
39
+
40
+
41
+ def choices(self, changelist):
42
+ yield {
43
+ "selected": self.lookup_val is None,
44
+ "query_string": changelist.get_query_string(
45
+ remove=[self.lookup_kwarg, self.lookup_kwarg_isnull]
46
+ ),
47
+ "display": _("All"),
48
+ }
49
+ none_title = ""
50
+
51
+ titles_map = {l:t for l, t in self.field.flatchoices}
52
+
53
+ for val in self.lookup_choices:
54
+ if val is None:
55
+ none_title = titles_map.get(val, val)
56
+ continue
57
+ val = str(val)
58
+ yield {
59
+ "selected": self.lookup_val == val,
60
+ "query_string": changelist.get_query_string(
61
+ {self.lookup_kwarg: val}, [self.lookup_kwarg_isnull]
62
+ ),
63
+ "display": titles_map.get(val, val),
64
+ }
65
+
66
+ if none_title:
67
+ yield {
68
+ "selected": bool(self.lookup_val_isnull),
69
+ "query_string": changelist.get_query_string(
70
+ {self.lookup_kwarg_isnull: "True"}, [self.lookup_kwarg]
71
+ ),
72
+ "display": none_title,
73
+ }