diff --git a/backend/backend/celery.py b/backend/backend/celery.py index 2c277f5c..5c211673 100644 --- a/backend/backend/celery.py +++ b/backend/backend/celery.py @@ -6,14 +6,4 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") app = Celery("dango_wol") app.config_from_object("django.conf:settings", namespace="CELERY") -app.conf.beat_schedule = { - "ping_devices": { - "task": "wol.tasks.status", - "schedule": int(os.getenv("PING_INTERVAL")) - }, - "scheduled_wakes": { - "task": "wol.tasks.scheduled_wakes", - "schedule": 1 - } -} app.autodiscover_tasks() diff --git a/backend/backend/settings.py b/backend/backend/settings.py index b0837289..e8ca76ad 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -38,8 +38,9 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'wol', - 'channels' + 'channels', + 'django_celery_beat', + 'wol' ] MIDDLEWARE = [ @@ -74,6 +75,7 @@ WSGI_APPLICATION = 'backend.wsgi.application' WSGI_APPLICATION = 'backend.wsgi.application' ASGI_APPLICATION = 'backend.asgi.application' CELERY_BROKER_URL = f"redis://{os.getenv('REDIS_HOST', '127.0.0.1')}:{os.getenv('REDIS_PORT', 6379)}" +CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' # Database @@ -119,7 +121,9 @@ CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { - "hosts": [(os.getenv("REDIS_HOST", "127.0.0.1"), os.getenv("REDIS_PORT", 6379))] + "hosts": [(os.getenv("REDIS_HOST", "127.0.0.1"), os.getenv("REDIS_PORT", 6379))], + "capacity": 1000, + "expiry": 10 } } } diff --git a/backend/run.sh b/backend/run.sh new file mode 100644 index 00000000..eb6b7712 --- /dev/null +++ b/backend/run.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +# wait for db and redis +if [ "${DB_TYPE}" != "sqlite" ]; then + /usr/bin/env bash ./wait-for-it.sh "${DB_HOST}":"${DB_PORT}" -t 300 -s + sleep 20 +fi +/usr/bin/env bash ./wait-for-it.sh "${REDIS_HOST}":"${REDIS_PORT}" -t 300 -s + +# init django +python manage.py makemigrations +python manage.py migrate +python manage.py collectstatic --noinput +python manage shell < setup.py + +# set ping interval +if [ -z "$PING_INTERVAL" ]; then + PING_INTERVAL=5 +elif [ "$PING_INTERVAL" -lt 5 ]; then + echo "" + echo "Ping interval lower than 5 seconds is not recommended. Please use an interval of 5 seconds or higher. Automatically set to 5 seconds." + echo "" + PING_INTERVAL=5 +fi + +celery -A backend worker & +celery -A backend beat & +gunicorn --bind 0.0.0.0:"$DJANGO_PORT" --workers 4 backend.asgi:application -k uvicorn.workers.UvicornWorker diff --git a/backend/setup.py b/backend/setup.py new file mode 100644 index 00000000..fccada81 --- /dev/null +++ b/backend/setup.py @@ -0,0 +1,37 @@ +import os + +from django.contrib.auth.models import User + +from wol.models import Settings, Websocket +from django_celery_beat.models import PeriodicTask, IntervalSchedule + + +# create superuser +if not User.objects.filter(username=os.getenv("DJANGO_SUPERUSER_USER")).exists(): + User.objects.create_superuser(os.getenv("DJANGO_SUPERUSER_USER"), password=os.getenv("DJANGO_SUPERUSER_PASSWORD")) +else: + print('Django user exists') + +# reset visitors +[i.delete() for i in Websocket.objects.all()] +Websocket.objects.create(visitors=0) + +# notifications +Settings.objects.update_or_create( + id=1, + defaults={ + "enable_notifications": os.getenv("ENABLE_NOTIFICATIONS") + } +) + +# register device ping task +schedule, created = IntervalSchedule.objects.get_or_create( + every=int(os.getenv("PING_INTERVAL")), + period=IntervalSchedule.SECONDS, +) +if created: + PeriodicTask.objects.create( + interval=schedule, + name="Ping all devices", + task="wol.tasks.ping_all_devices" + ) diff --git a/backend/wol/consumers.py b/backend/wol/consumers.py index 92ec72c8..a0a56365 100644 --- a/backend/wol/consumers.py +++ b/backend/wol/consumers.py @@ -8,6 +8,7 @@ from django.utils.timezone import make_aware from wol.models import Device, Port, Websocket from wol.wake import wake +from django_celery_beat.models import PeriodicTask, ClockedSchedule, CrontabSchedule class WSConsumer(AsyncWebsocketConsumer): @@ -46,9 +47,8 @@ class WSConsumer(AsyncWebsocketConsumer): async def receive(self, text_data=None): received = json.loads(text_data) if received["type"] == "wake": - dev = await self.get_json_from_device_id(received["id"]) - wake(dev["fields"]["mac"], dev["fields"] - ["ip"], dev["fields"]["netmask"]) + dev = await self.get_device(received["id"]) + wake(dev.mac, dev.ip, dev.netmask) await self.channel_layer.group_send( "wol", { @@ -59,38 +59,6 @@ class WSConsumer(AsyncWebsocketConsumer): } } ) - elif received["type"] == "add_schedule": - if not received["datetime"]: - return - d = make_aware(parse_datetime(received["datetime"])) - await self.add_schedule(received["id"], d) - dev = await self.get_json_from_device_id(received["id"]) - await self.channel_layer.group_send( - "wol", { - "type": "send_group", - "message": { - "add_schedule": { - "id": received["id"], - "name": dev["fields"]["name"], - "datetime": str(d.isoformat()) - } - } - } - ) - elif received["type"] == "delete_schedule": - await self.delete_schedule(received["id"]) - dev = await self.get_json_from_device_id(received["id"]) - await self.channel_layer.group_send( - "wol", { - "type": "send_group", - "message": { - "delete_schedule": { - "id": received["id"], - "name": dev["fields"]["name"] - } - } - } - ) elif received["type"] == "delete_device": await self.delete_device(received["id"]) await self.channel_layer.group_send( @@ -106,6 +74,8 @@ class WSConsumer(AsyncWebsocketConsumer): await self.update_device(received["data"]) elif received["type"] == "update_port": await self.update_port(received["data"]) + elif received["type"] == "celery": + await self.celery_create_scheduled_wake(received["data"]) async def send_group(self, event): @@ -127,6 +97,12 @@ class WSConsumer(AsyncWebsocketConsumer): def get_visitors(self): return Websocket.objects.first().visitors + @database_sync_to_async + def get_device(self, id): + dev = Device.objects.filter(id=id).first() + return dev + + @database_sync_to_async def get_all_devices(self): devices = Device.objects.all() @@ -138,7 +114,11 @@ class WSConsumer(AsyncWebsocketConsumer): "ip": dev.ip, "mac": dev.mac, "netmask": dev.netmask, - "ports": [] + "ports": [], + "cron": { + "enabled": False, + "value": "" + } } for p in Port.objects.all().order_by("number"): data["ports"].append({ @@ -147,6 +127,14 @@ class WSConsumer(AsyncWebsocketConsumer): "checked": False, "open": False }) + try: + task = PeriodicTask.objects.filter(name=data["name"], task="wol.tasks.scheduled_wake", crontab_id__isnull=False).get() + if task: + cron = CrontabSchedule.objects.get(id=task.crontab_id) + data["cron"]["enabled"] = task.enabled + data["cron"]["value"] = " ".join([cron.minute, cron.hour, cron.day_of_week, cron.day_of_month, cron.month_of_year]) + except PeriodicTask.DoesNotExist: + pass d.append(data) return d @@ -174,6 +162,33 @@ class WSConsumer(AsyncWebsocketConsumer): p = Port.objects.get(number=port["number"]) obj.port.remove(p) + if data["cron"]["enabled"]: + cron_value = data["cron"]["value"].strip().split(" ") + if not len(cron_value) == 5: + return + minute, hour, dom, month, dow = cron_value + schedule, _ = CrontabSchedule.objects.get_or_create( + minute=minute, + hour=hour, + day_of_week=dow, + day_of_month=dom, + month_of_year=month + ) + PeriodicTask.objects.update_or_create( + name=data["name"], + defaults={ + "crontab": schedule, + "task": "wol.tasks.scheduled_wake", + "args": json.dumps([data["id"]]), + "enabled": True + } + ) + else: + for task in PeriodicTask.objects.filter(name=data["name"], task="wol.tasks.scheduled_wake"): + task.enabled = False + task.save() + + @database_sync_to_async def update_port(self, data): if data.get("name"): @@ -187,18 +202,14 @@ class WSConsumer(AsyncWebsocketConsumer): Port.objects.filter(number=data["number"]).delete() @database_sync_to_async - def get_json_from_device_id(self, id): - dev = Device.objects.filter(id=id) - return serializers.serialize("python", dev)[0] - - @database_sync_to_async - def add_schedule(self, id, datetime): - dev = Device.objects.filter(id=id).get() - dev.scheduled_wake = datetime - dev.save() - - @database_sync_to_async - def delete_schedule(self, id): - dev = Device.objects.filter(id=id).get() - dev.scheduled_wake = None - dev.save() + def celery_create_scheduled_wake(self, data): + aware_time = make_aware(parse_datetime(data["time"])) + schedule, _ = ClockedSchedule.objects.get_or_create( + clocked_time=aware_time + ) + PeriodicTask.objects.get_or_create( + clocked=schedule, + name=data["name"], + task="wol.tasks.scheduled_wake", + args=json.dumps([data["id"]]) + ) diff --git a/backend/wol/tasks.py b/backend/wol/tasks.py index 1a5a973f..47e32124 100644 --- a/backend/wol/tasks.py +++ b/backend/wol/tasks.py @@ -5,8 +5,7 @@ import threading from asgiref.sync import async_to_sync from celery import shared_task from channels.layers import get_channel_layer -from django.core import serializers -from django.utils import timezone +from django_celery_beat.models import PeriodicTask, CrontabSchedule from wol.models import Device, Port, Websocket from wol.wake import wake @@ -37,8 +36,14 @@ class WolDevice: "mac": dev.mac, "netmask": dev.netmask, "up": False, - "ports": [] + "ports": [], + "cron": { + "enabled": False, + "value": "" + } } + + # add ports for p in Port.objects.all().order_by("number"): data["ports"].append({ "number": p.number, @@ -47,6 +52,7 @@ class WolDevice: "open": False }) + # set status for device and ports if self.ping_device(dev.ip): data["up"] = True for port in dev.port.all(): @@ -58,6 +64,16 @@ class WolDevice: data["ports"][index]["checked"] = True data["ports"][index]["open"] = False + # set cron for scheduled wake + try: + task = PeriodicTask.objects.filter(name=data["name"], task="wol.tasks.scheduled_wake", crontab_id__isnull=False).get() + if task: + cron = CrontabSchedule.objects.get(id=task.crontab_id) + data["cron"]["enabled"] = task.enabled + data["cron"]["value"] = " ".join([cron.minute, cron.hour, cron.day_of_week, cron.day_of_month, cron.month_of_year]) + except PeriodicTask.DoesNotExist: + pass + async_to_sync(channel_layer.group_send)( "wol", {"type": "send_group", "message": { "type": "status", @@ -66,7 +82,7 @@ class WolDevice: @shared_task -def status(): +def ping_all_devices(): if Websocket.objects.first().visitors == 0: return @@ -79,26 +95,12 @@ def status(): @shared_task -def scheduled_wakes(): - if Websocket.objects.first().visitors == 0: +def scheduled_wake(id): + try: + device = Device.objects.get(id=id) + except Device.DoesNotExist: + for task in PeriodicTask.objects.filter(args=id): + task.delete() return - devices = Device.objects.all() - - for dev in devices: - if dev.scheduled_wake and dev.scheduled_wake <= timezone.now(): - wake(dev.mac, dev.ip, dev.netmask) - dev.scheduled_wake = None - dev.save() - async_to_sync(channel_layer.group_send)( - "wol", { - "type": "send_group", - "message": { - "type": "wake_schedule", - "message": { - "id": dev.id, - "name": dev.name - } - } - } - ) + wake(device.mac, device.ip, device.netmask) diff --git a/backend/wol/tests.py b/backend/wol/tests.py index 7ce503c2..32bf5a30 100644 --- a/backend/wol/tests.py +++ b/backend/wol/tests.py @@ -1,3 +1,7 @@ from django.test import TestCase # Create your tests here. + +from celery.schedules import crontab_parser + +print(dir(crontab_parser().parse("0 9 * * 1,5"))) diff --git a/frontend/src/components/DeviceCard.svelte b/frontend/src/components/DeviceCard.svelte index b33e7974..7385cdcb 100644 --- a/frontend/src/components/DeviceCard.svelte +++ b/frontend/src/components/DeviceCard.svelte @@ -1,6 +1,7 @@