django-eventspipe 0.1.0b1__tar.gz

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.
Files changed (30) hide show
  1. django_eventspipe-0.1.0b1/LICENSE.txt +21 -0
  2. django_eventspipe-0.1.0b1/PKG-INFO +24 -0
  3. django_eventspipe-0.1.0b1/README.md +1 -0
  4. django_eventspipe-0.1.0b1/pyproject.toml +30 -0
  5. django_eventspipe-0.1.0b1/setup.cfg +4 -0
  6. django_eventspipe-0.1.0b1/src/__init__.py +0 -0
  7. django_eventspipe-0.1.0b1/src/admin.py +218 -0
  8. django_eventspipe-0.1.0b1/src/apps.py +10 -0
  9. django_eventspipe-0.1.0b1/src/decorators.py +53 -0
  10. django_eventspipe-0.1.0b1/src/django_eventspipe.egg-info/PKG-INFO +24 -0
  11. django_eventspipe-0.1.0b1/src/django_eventspipe.egg-info/SOURCES.txt +28 -0
  12. django_eventspipe-0.1.0b1/src/django_eventspipe.egg-info/dependency_links.txt +1 -0
  13. django_eventspipe-0.1.0b1/src/django_eventspipe.egg-info/requires.txt +2 -0
  14. django_eventspipe-0.1.0b1/src/django_eventspipe.egg-info/top_level.txt +12 -0
  15. django_eventspipe-0.1.0b1/src/exceptions.py +2 -0
  16. django_eventspipe-0.1.0b1/src/migrations/0001_initial.py +91 -0
  17. django_eventspipe-0.1.0b1/src/migrations/__init__.py +0 -0
  18. django_eventspipe-0.1.0b1/src/models/__init__.py +7 -0
  19. django_eventspipe-0.1.0b1/src/models/artifact.py +29 -0
  20. django_eventspipe-0.1.0b1/src/models/pipeline.py +173 -0
  21. django_eventspipe-0.1.0b1/src/models/pipeline_artifact.py +37 -0
  22. django_eventspipe-0.1.0b1/src/models/pipeline_definition.py +77 -0
  23. django_eventspipe-0.1.0b1/src/models/pipeline_definition_task_definition.py +8 -0
  24. django_eventspipe-0.1.0b1/src/models/task.py +86 -0
  25. django_eventspipe-0.1.0b1/src/models/task_definition.py +40 -0
  26. django_eventspipe-0.1.0b1/src/signals.py +15 -0
  27. django_eventspipe-0.1.0b1/src/tests.py +3 -0
  28. django_eventspipe-0.1.0b1/src/urls.py +7 -0
  29. django_eventspipe-0.1.0b1/src/utils.py +38 -0
  30. django_eventspipe-0.1.0b1/src/views.py +16 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Giuseppe Corti <giuseppe.corti@protonmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.1
2
+ Name: django_eventspipe
3
+ Version: 0.1.0b1
4
+ Summary: TODO
5
+ Author-email: Giuseppe Corti <giuseppe.corti@pm.me>
6
+ Project-URL: Homepage, https://github.com/r3vn/django_eventspipe
7
+ Project-URL: Issues, https://github.com/r3vn/django_eventspipe/issues
8
+ Classifier: Environment :: Web Environment
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: Implementation :: CPython
16
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE.txt
21
+ Requires-Dist: django>=5.0
22
+ Requires-Dist: celery>=5.4
23
+
24
+ TODO
@@ -0,0 +1 @@
1
+ TODO
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "django_eventspipe"
3
+ version = "0.1.0b1"
4
+ authors = [
5
+ { name="Giuseppe Corti", email="giuseppe.corti@pm.me" },
6
+ ]
7
+ description = "TODO"
8
+ readme = "README.md"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "django>=5.0",
12
+ "celery>=5.4"
13
+ ]
14
+ classifiers = [
15
+ "Environment :: Web Environment",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: Implementation :: CPython",
23
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/r3vn/django_eventspipe"
29
+ Issues = "https://github.com/r3vn/django_eventspipe/issues"
30
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,218 @@
1
+ from django import forms
2
+ from django.contrib import admin
3
+ from django.utils.html import format_html
4
+ from django.urls import reverse
5
+
6
+ from .utils import linkify, PrettyJSONEncoder
7
+
8
+ from .models import (
9
+ PipelineDefinition,
10
+ PipelineDefinitionTaskDefinition,
11
+ Task,
12
+ TaskDefinition,
13
+ Pipeline,
14
+ Artifact,
15
+ PipelineArtifact
16
+ )
17
+
18
+ class InlinePipeline(admin.TabularInline):
19
+ model = PipelineArtifact
20
+ extra = 0
21
+ ordering = ("pk",)
22
+ can_delete = False
23
+ readonly_fields = [
24
+ 'pk',
25
+ 'pipeline',
26
+ ]
27
+
28
+ class InlineTask(admin.TabularInline):
29
+ model = Task
30
+ extra = 0
31
+ ordering = ("pk",)
32
+ can_delete = False
33
+ readonly_fields = [
34
+ '_status',
35
+ '_name',
36
+ 'node',
37
+ 'start_ts',
38
+ 'end_ts'
39
+ ]
40
+ exclude = [
41
+ 'definition',
42
+ 'pipeline_definition',
43
+ 'status'
44
+ ]
45
+
46
+ def _status(self, obj):
47
+ """
48
+ Read job's status properly
49
+ """
50
+ colors = ["#2196F3","#04AA6D","#f23232", "#f8f8f8", "#ff9800"]
51
+ return format_html(
52
+ "<span style=\"background-color:%s;display:block;text-align:center;font-size:0.6rem;padding:1px;max-width:55px;\"><b>%s</b></span>"
53
+ % (
54
+ colors[obj.status],
55
+ obj.get_status_display().upper()
56
+ )
57
+ )
58
+
59
+ def _name(self, obj):
60
+ return "%s" % obj.definition.task_definition.function
61
+
62
+ class JsonOptionsForm(forms.ModelForm):
63
+ options = forms.JSONField(
64
+ encoder=PrettyJSONEncoder,
65
+ required=False
66
+ )
67
+
68
+ class InlineTaskDefinition(admin.TabularInline):
69
+ model = PipelineDefinitionTaskDefinition
70
+ extra = 0
71
+ ordering = ("order",)
72
+ #form = JsonOptionsForm
73
+
74
+ exclude = ['options']
75
+
76
+ def _name(self, obj):
77
+ """
78
+ Get Job function name from JobDefinition object
79
+ """
80
+ return "%s" % obj.task_definition.function
81
+
82
+
83
+ @admin.register(Pipeline)
84
+ class PipelineAdmin(admin.ModelAdmin):
85
+ list_display = (
86
+ "pk",
87
+ "_status",
88
+ "name",
89
+ linkify("user"),
90
+ "start_ts",
91
+ "end_ts"
92
+ )
93
+ readonly_fields = []
94
+ inlines = [InlineTask]
95
+
96
+ # https://stackoverflow.com/a/19884095
97
+ def get_readonly_fields(self, request, obj=None):
98
+ return list(self.readonly_fields) + \
99
+ [field.name for field in obj._meta.fields] + \
100
+ [field.name for field in obj._meta.many_to_many]
101
+
102
+ def has_add_permission(self, request):
103
+ return False
104
+
105
+ def has_delete_permission(self, request, obj=None):
106
+ return False
107
+
108
+ def _status(self, obj):
109
+ """
110
+ Read job's status properly
111
+ """
112
+ colors = ["#2196F3","#04AA6D","#f23232", "#f8f8f8"]
113
+ return format_html(
114
+ "<span style=\"background-color:%s;display:block;text-align:center;font-size:0.6rem;padding:1px;max-width:55px;\"><b>%s</b></span>"
115
+ % (
116
+ colors[obj.status],
117
+ obj.get_status_display().upper()
118
+ )
119
+ )
120
+
121
+ @admin.register(PipelineDefinition)
122
+ class PipelineDefinitionAdmin(admin.ModelAdmin):
123
+ list_display = (
124
+ "pk",
125
+ "enabled",
126
+ "event",
127
+ "filters",
128
+ "tasks_definition"
129
+ )
130
+ inlines = [InlineTaskDefinition]
131
+ form = JsonOptionsForm
132
+
133
+ @admin.action(description="Disable selected PipelineDefinition")
134
+ def disable_selection(self, request, queryset):
135
+ for object in queryset:
136
+
137
+ object.enabled = False
138
+ object.save()
139
+
140
+ @admin.action(description="Enable selected PipelineDefinition")
141
+ def enable_selection(self, request, queryset):
142
+ for object in queryset:
143
+
144
+ object.enabled = True
145
+ object.save()
146
+
147
+ @admin.action(description="Duplicate selected PipelineDefinition")
148
+ def duplicate_selection(self, request, queryset):
149
+ for object in queryset:
150
+
151
+ task_definitions = PipelineDefinitionTaskDefinition.objects.filter(pipeline_definition=object)
152
+
153
+ object.id = None
154
+ object.save()
155
+
156
+ for task_definition in task_definitions:
157
+ task_definition.id = None
158
+ task_definition.pipeline_definition = object
159
+ task_definition.save()
160
+
161
+ actions = [duplicate_selection, enable_selection, disable_selection]
162
+
163
+ def tasks_definition(self, obj):
164
+ """
165
+ Return a "pretty" tasks execution order view
166
+ """
167
+ rt_string = ""
168
+ for definition in PipelineDefinitionTaskDefinition.objects.filter(pipeline_definition=obj).order_by('order'):
169
+ if definition.enabled:
170
+ job_name = definition.task_definition.function
171
+ else:
172
+ # task is disabled, Strikethrough the function name
173
+ job_name = "<s style=\"color:var(--delete-button-bg)\">%s</s>" % definition.task_definition.function
174
+
175
+ rt_string = "%s <span style=\"color:var(--link-fg)\">→</span> %s" % (rt_string, job_name)
176
+
177
+ return format_html(rt_string)
178
+
179
+ @admin.register(TaskDefinition)
180
+ class TaskDefinitionAdmin(admin.ModelAdmin):
181
+ list_display = (
182
+ "function",
183
+ "description"
184
+ )
185
+ ordering = ('function',)
186
+
187
+ @admin.register(Artifact)
188
+ class ArtifactAdmin(admin.ModelAdmin):
189
+ list_display = (
190
+ "pk",
191
+ "name",
192
+ "size",
193
+ "md5sum",
194
+ "timestamp",
195
+ "download"
196
+ )
197
+
198
+ inlines = [InlinePipeline]
199
+ readonly_fields = []
200
+
201
+ # https://stackoverflow.com/a/19884095
202
+ def get_readonly_fields(self, request, obj=None):
203
+ return list(self.readonly_fields) + \
204
+ [field.name for field in obj._meta.fields] + \
205
+ [field.name for field in obj._meta.many_to_many]
206
+
207
+ def has_add_permission(self, request):
208
+ return False
209
+
210
+ def has_delete_permission(self, request, obj=None):
211
+ return False
212
+
213
+ def download(self, obj: Artifact) -> str:
214
+ url = reverse('get_artifact', args=(obj.pk,))
215
+ return format_html("<a class='button' href='%s'>📝 DOWNLOAD</a>" % url)
216
+
217
+ def size(self, obj: Artifact) -> str:
218
+ return "%s KB" % str(obj.get_size())
@@ -0,0 +1,10 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoEventspipeConfig(AppConfig):
5
+ default_auto_field = 'django.db.models.BigAutoField'
6
+ name = 'django_eventspipe'
7
+ verbose_name = 'Automations'
8
+
9
+ def ready(self):
10
+ import django_eventspipe.signals
@@ -0,0 +1,53 @@
1
+ import traceback
2
+ import platform
3
+
4
+ from functools import wraps
5
+
6
+ from .models import Pipeline, Task
7
+ from .exceptions import TaskFailed
8
+
9
+ def tracked_task(func):
10
+ @wraps(func)
11
+ def wrapper(self, context: dict[str, object], *args, **kw) -> dict[str, object]:
12
+
13
+ # HACK: Get task name
14
+ if str(func.__name__) in str(func.__module__):
15
+ taskname = str(func.__module__)
16
+ else:
17
+ taskname = "%s.%s" % (func.__module__, func.__name__)
18
+
19
+ # Get Pipeline object
20
+ self.pipeline = Pipeline.objects.get(pk=context["pipeline"])
21
+
22
+ # Get current pipeline's task
23
+ pipeline_task = Task.objects.get(
24
+ pipeline=self.pipeline,
25
+ definition__task_definition__function=taskname
26
+ )
27
+
28
+ self.pipeline.log("executing '%s'..." % taskname)
29
+
30
+ # Start Task's tracking
31
+ pipeline_task.tracking_start(node=platform.node())
32
+
33
+ try:
34
+ # Execute Task
35
+ result = func(self, context, *args, **kw)
36
+
37
+ # Set Task's status as success
38
+ pipeline_task.tracking_update(1)
39
+
40
+ except Exception as E:
41
+ # Log the Exception
42
+ self.pipeline.log(str(traceback.format_exc()))
43
+
44
+ # Set Task's status as failed
45
+ pipeline_task.tracking_update(2)
46
+
47
+ raise TaskFailed
48
+
49
+ self.pipeline.log("'%s' execution complete." % taskname)
50
+
51
+ return result
52
+
53
+ return wrapper
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.1
2
+ Name: django_eventspipe
3
+ Version: 0.1.0b1
4
+ Summary: TODO
5
+ Author-email: Giuseppe Corti <giuseppe.corti@pm.me>
6
+ Project-URL: Homepage, https://github.com/r3vn/django_eventspipe
7
+ Project-URL: Issues, https://github.com/r3vn/django_eventspipe/issues
8
+ Classifier: Environment :: Web Environment
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: Implementation :: CPython
16
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE.txt
21
+ Requires-Dist: django>=5.0
22
+ Requires-Dist: celery>=5.4
23
+
24
+ TODO
@@ -0,0 +1,28 @@
1
+ LICENSE.txt
2
+ README.md
3
+ pyproject.toml
4
+ src/__init__.py
5
+ src/admin.py
6
+ src/apps.py
7
+ src/decorators.py
8
+ src/exceptions.py
9
+ src/signals.py
10
+ src/tests.py
11
+ src/urls.py
12
+ src/utils.py
13
+ src/views.py
14
+ src/django_eventspipe.egg-info/PKG-INFO
15
+ src/django_eventspipe.egg-info/SOURCES.txt
16
+ src/django_eventspipe.egg-info/dependency_links.txt
17
+ src/django_eventspipe.egg-info/requires.txt
18
+ src/django_eventspipe.egg-info/top_level.txt
19
+ src/migrations/0001_initial.py
20
+ src/migrations/__init__.py
21
+ src/models/__init__.py
22
+ src/models/artifact.py
23
+ src/models/pipeline.py
24
+ src/models/pipeline_artifact.py
25
+ src/models/pipeline_definition.py
26
+ src/models/pipeline_definition_task_definition.py
27
+ src/models/task.py
28
+ src/models/task_definition.py
@@ -0,0 +1,2 @@
1
+ django>=5.0
2
+ celery>=5.4
@@ -0,0 +1,12 @@
1
+ __init__
2
+ admin
3
+ apps
4
+ decorators
5
+ exceptions
6
+ migrations
7
+ models
8
+ signals
9
+ tests
10
+ urls
11
+ utils
12
+ views
@@ -0,0 +1,2 @@
1
+ class TaskFailed(Exception):
2
+ pass
@@ -0,0 +1,91 @@
1
+ # Generated by Django 5.0.6 on 2024-09-08 12:57
2
+
3
+ import django.db.models.deletion
4
+ import django_eventspipe.utils
5
+ from django.conf import settings
6
+ from django.db import migrations, models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ initial = True
12
+
13
+ dependencies = [
14
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name='Artifact',
20
+ fields=[
21
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22
+ ('name', models.CharField(max_length=1024)),
23
+ ('data', models.BinaryField(blank=True)),
24
+ ('timestamp', models.DateTimeField(auto_now_add=True)),
25
+ ('md5sum', models.CharField(blank=True, editable=False, max_length=32, unique=True)),
26
+ ],
27
+ ),
28
+ migrations.CreateModel(
29
+ name='PipelineDefinition',
30
+ fields=[
31
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
32
+ ('event', models.CharField(max_length=256)),
33
+ ('filters', models.JSONField(blank=True, default=dict, null=True)),
34
+ ('options', models.JSONField(blank=True, default=dict, null=True)),
35
+ ('enabled', models.BooleanField(default=True)),
36
+ ],
37
+ ),
38
+ migrations.CreateModel(
39
+ name='TaskDefinition',
40
+ fields=[
41
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
42
+ ('function', models.CharField(max_length=512, unique=True)),
43
+ ('description', models.TextField(blank=True, null=True)),
44
+ ],
45
+ ),
46
+ migrations.CreateModel(
47
+ name='Pipeline',
48
+ fields=[
49
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
50
+ ('node', models.CharField(default='undefined', max_length=256)),
51
+ ('name', models.CharField(max_length=128)),
52
+ ('status', models.IntegerField(choices=[(0, 'running'), (1, 'success'), (2, 'error'), (3, 'queued')], default=3)),
53
+ ('tasks_count', models.IntegerField(default=0)),
54
+ ('current_task', models.IntegerField(default=0)),
55
+ ('start_ts', models.DateTimeField(auto_now_add=True)),
56
+ ('end_ts', models.DateTimeField(blank=True, null=True)),
57
+ ('user', models.ForeignKey(on_delete=models.SET(django_eventspipe.utils.get_sentinel_user), to=settings.AUTH_USER_MODEL)),
58
+ ('definition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_eventspipe.pipelinedefinition')),
59
+ ],
60
+ ),
61
+ migrations.CreateModel(
62
+ name='PipelineArtifact',
63
+ fields=[
64
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
65
+ ('artifact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_eventspipe.artifact')),
66
+ ('pipeline', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_eventspipe.pipeline')),
67
+ ],
68
+ ),
69
+ migrations.CreateModel(
70
+ name='PipelineDefinitionTaskDefinition',
71
+ fields=[
72
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
73
+ ('enabled', models.BooleanField(default=True)),
74
+ ('order', models.IntegerField(default=20)),
75
+ ('pipeline_definition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_eventspipe.pipelinedefinition')),
76
+ ('task_definition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_eventspipe.taskdefinition')),
77
+ ],
78
+ ),
79
+ migrations.CreateModel(
80
+ name='Task',
81
+ fields=[
82
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
83
+ ('status', models.IntegerField(choices=[(0, 'running'), (1, 'success'), (2, 'error'), (3, 'queued'), (4, 'skipped')], default=2)),
84
+ ('node', models.CharField(default='undefined', max_length=256)),
85
+ ('start_ts', models.DateTimeField(blank=True, null=True)),
86
+ ('end_ts', models.DateTimeField(blank=True, null=True)),
87
+ ('definition', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_eventspipe.pipelinedefinitiontaskdefinition')),
88
+ ('pipeline', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_eventspipe.pipeline')),
89
+ ],
90
+ ),
91
+ ]
File without changes
@@ -0,0 +1,7 @@
1
+ from .task_definition import TaskDefinition
2
+ from .artifact import Artifact
3
+ from .pipeline_definition import PipelineDefinition
4
+ from .pipeline_definition_task_definition import PipelineDefinitionTaskDefinition
5
+ from .pipeline import Pipeline
6
+ from .task import Task
7
+ from .pipeline_artifact import PipelineArtifact
@@ -0,0 +1,29 @@
1
+ import hashlib
2
+ from django.db import models
3
+
4
+ class Artifact(models.Model):
5
+ name = models.CharField(max_length=1024)
6
+ data = models.BinaryField(blank=True)
7
+ timestamp = models.DateTimeField(auto_now_add=True)
8
+ md5sum = models.CharField(max_length=32, blank=True, editable=False, unique=True) # Unique MD5 checksum
9
+
10
+ @classmethod
11
+ def get_or_create(cls, name:str, data:bytes) -> object:
12
+ # Compute the MD5 checksum of the data
13
+ md5sum = hashlib.md5(data).hexdigest()
14
+
15
+ if cls.objects.filter(md5sum=md5sum).exists():
16
+ return cls.objects.get(md5sum=md5sum)
17
+
18
+ file = cls(name=name,data=data,md5sum=md5sum)
19
+ file.save()
20
+
21
+ return file
22
+
23
+ def get_size(self) -> float:
24
+ """
25
+ Get size in KB of a file
26
+ """
27
+ return len(self.data) / 1000
28
+
29
+
@@ -0,0 +1,173 @@
1
+ import platform
2
+
3
+ from celery import chain
4
+
5
+ from django.db import models
6
+ from django.contrib.auth.models import User
7
+ from django.contrib.admin.models import LogEntry, ADDITION
8
+ from django.contrib.contenttypes.models import ContentType
9
+ from django.apps import apps
10
+ from django.utils import timezone
11
+
12
+ from django_eventspipe.utils import get_sentinel_user
13
+
14
+ class Pipeline(models.Model):
15
+
16
+ STATUS_CHOICES = [
17
+ (0, "running"),
18
+ (1, "success"),
19
+ (2, "error"),
20
+ (3, "queued")
21
+ ]
22
+
23
+ node = models.CharField(max_length=256, default="undefined")
24
+ name = models.CharField(max_length=128)
25
+ status = models.IntegerField(default=3, choices=STATUS_CHOICES)
26
+ definition = models.ForeignKey('django_eventspipe.PipelineDefinition', on_delete=models.SET_NULL, blank=True, null=True)
27
+ tasks_count = models.IntegerField(default=0)
28
+ current_task = models.IntegerField(default=0)
29
+ start_ts = models.DateTimeField(auto_now_add=True)
30
+ end_ts = models.DateTimeField(blank=True, null=True)
31
+ user = models.ForeignKey(User, on_delete=models.SET(get_sentinel_user))
32
+
33
+ @classmethod
34
+ def new_from_event(cls, event: dict) -> object | bool:
35
+ """
36
+ This function attempt to create and execute a `Pipeline` objects from a dict.
37
+ `Pipeline` objects require a `PipelineDefinition` object defined in order to start.
38
+ Filtered `PipelineDefinition` objects always have priority over generic ones,
39
+ this allow replacing 's definitions only when required.
40
+ """
41
+ PipelineDefinition = apps.get_model("django_eventspipe.PipelineDefinition")
42
+
43
+ # Check for available info for this event
44
+ if "info" in event.keys():
45
+ pipeline_name = "%s %s" % (event["name"], event["info"])
46
+ else:
47
+ pipeline_name = event["name"]
48
+
49
+ # Create the pipeline objects
50
+ pipelines = []
51
+ definitions = PipelineDefinition.get_definitions(event)
52
+
53
+ if len(definitions) <= 0:
54
+ # no such pipeline definition
55
+ return False
56
+
57
+ for definition in definitions:
58
+ # Create Pipeline object
59
+ pipeline = cls(
60
+ name=pipeline_name,
61
+ user=User.objects.get(pk=event["user"]),
62
+ node=platform.node(),
63
+ definition=definition
64
+ )
65
+ pipeline.save()
66
+
67
+ # Run Pipeline
68
+ pipeline.run(event)
69
+ pipelines.append(pipeline)
70
+
71
+ return pipelines
72
+
73
+ def __str__(self):
74
+ return "Pipeline #%d" % self.pk
75
+
76
+ def save_artifact(self, file_name: str, file_data: bytes) -> bool:
77
+ """
78
+ Save an artifact for this Pipeline
79
+ """
80
+
81
+ PipelineArtifact = apps.get_model("django_eventspipe.PipelineArtifact")
82
+
83
+ return PipelineArtifact.add_artifact(
84
+ pipeline = self,
85
+ file_name = file_name,
86
+ file_data = file_data,
87
+ )
88
+
89
+ def get_artifacts(self) -> dict[str, object]:
90
+ """
91
+ Get stored artifacts for this Pipeline
92
+ """
93
+ PipelineArtifact = apps.get_model("django_eventspipe.PipelineArtifact")
94
+
95
+ return PipelineArtifact.get_artifacts(self)
96
+
97
+ def run(self, event: dict) -> None:
98
+ """
99
+ Execute a `Pipeline`
100
+ """
101
+ Task = apps.get_model("django_eventspipe.Task")
102
+
103
+ # add initial pipeline's log entry
104
+ self.log("Event received %s" % str(event))
105
+
106
+ # Get initial context from event
107
+ context = {
108
+ "pipeline" : self.pk
109
+ }
110
+
111
+ # retrive context data from event
112
+ for data in event.keys():
113
+ context[data] = event[data]
114
+
115
+ # retrive context data from PipelineDefinitinon's options
116
+ for data in self.definition.options.keys():
117
+ context[data] = self.definition.options[data]
118
+
119
+ # Get Task definition for this event on this perimeter
120
+ pipeline_tasks = Task.create_tasks(self)
121
+
122
+ # Get celery chain for this pipeline
123
+ tasks_chain_list = self.definition.get_tasks_chain(context)
124
+
125
+ # No Tasks defined for this pipeline, exit
126
+ if len(pipeline_tasks) <= 0:
127
+ self.status = 1
128
+ self.tasks_count = 0
129
+ self.current_task = 0
130
+ self.end_ts = timezone.now()
131
+ self.save()
132
+
133
+ return None
134
+
135
+ # Set pipeline as queued
136
+ self.status = 3
137
+ self.tasks_count = len(pipeline_tasks)
138
+ self.save()
139
+
140
+ # Start celery chain
141
+ pipeline_chain = chain(tasks_chain_list)
142
+ pipeline_chain.apply_async()
143
+
144
+ def log(self, entry: str) -> None:
145
+ """
146
+ Add a LogEntry on a `Pipeline`
147
+ """
148
+ progress = "[%d/%d] " % (self.current_task, self.tasks_count)
149
+ log_entry = "%s%s" % (progress, entry)
150
+
151
+ # Add an ADDITION logentry for this asset
152
+ LogEntry.objects.log_action(
153
+ user_id=self.user.pk,
154
+ content_type_id=ContentType.objects.get_for_model(Pipeline).pk,
155
+ object_id=self.id,
156
+ object_repr="Pipeline #%d" % self.id,
157
+ action_flag=ADDITION,
158
+ change_message=log_entry
159
+ )
160
+
161
+ def fail(self) -> None:
162
+ """
163
+ Set a `Pipeline` and his uncompleted `Task` objects as failed.
164
+ """
165
+ Task = apps.get_model("django_eventspipe.Task")
166
+
167
+ # Update Pipeline object
168
+ self.status = 2
169
+ self.end_ts = timezone.now()
170
+ self.save()
171
+
172
+ # Update queued Tasks
173
+ Task.pipeline_failed(self)
@@ -0,0 +1,37 @@
1
+ from django.db import models
2
+ from django.apps import apps
3
+
4
+ class PipelineArtifact(models.Model):
5
+ """
6
+ Artifact -> Pipeline association
7
+ """
8
+ pipeline = models.ForeignKey("django_eventspipe.Pipeline", on_delete=models.CASCADE)
9
+ artifact = models.ForeignKey("django_eventspipe.Artifact", on_delete=models.CASCADE)
10
+
11
+ @classmethod
12
+ def add_artifact(cls, pipeline: object, file_name: str, file_data: bytes) -> bool:
13
+ """
14
+ Add a new artifact to the database
15
+ """
16
+ Artifact = apps.get_model("django_eventspipe.Artifact")
17
+
18
+ artifact = Artifact.get_or_create(name=file_name, data=file_data)
19
+
20
+ pipeline_artifact = cls(pipeline=pipeline, artifact=artifact)
21
+ pipeline_artifact.save()
22
+
23
+ return True
24
+
25
+ @classmethod
26
+ def get_artifacts(cls, pipeline: object) -> dict[str, object]:
27
+ """
28
+ Get stored artifacts for a Pipeline
29
+ """
30
+
31
+ out = {}
32
+ artifacts = cls.objects.filter(pipeline=pipeline)
33
+
34
+ for artifact in artifacts:
35
+ out[artifact.artifact.name] = bytes(artifact.artifact.data)
36
+
37
+ return out
@@ -0,0 +1,77 @@
1
+ from django.db import models
2
+ from django.apps import apps
3
+ from django.utils.module_loading import import_string
4
+
5
+ class PipelineDefinition(models.Model):
6
+ event = models.CharField(max_length=256)
7
+ filters = models.JSONField(blank=True, null=True, default=dict)
8
+ options = models.JSONField(blank=True, null=True, default=dict)
9
+ enabled = models.BooleanField(default=True)
10
+
11
+ @classmethod
12
+ def get_definitions(cls, event):
13
+ """
14
+ Get pipeline definitions for a given event.
15
+ """
16
+ event_name = event.get("name")
17
+
18
+ # Use ORM to fetch enabled definitions for the event in one query
19
+ all_definitions = cls.objects.filter(event=event_name, enabled=True)
20
+
21
+ # Separate generic and custom definitions
22
+ generic_definitions = []
23
+ custom_definitions = []
24
+
25
+ for definition in all_definitions:
26
+ filters = definition.filters or {}
27
+
28
+ # Check if filters exist, otherwise it's a generic definition
29
+ if filters:
30
+ match = all(
31
+ event.get(key) == value
32
+ for key, value in filters.items()
33
+ if key in event
34
+ )
35
+ if match:
36
+ custom_definitions.append(definition)
37
+ else:
38
+ generic_definitions.append(definition)
39
+
40
+ # Return custom definitions if available, otherwise return generic ones
41
+ return custom_definitions if custom_definitions else generic_definitions
42
+
43
+ def get_defined_tasks(self) -> list:
44
+ """
45
+ Get Tasks defined for this PipelineDefinition
46
+ """
47
+ PipelineDefinitionTaskDefinition = apps.get_model('django_eventspipe.PipelineDefinitionTaskDefinition')
48
+
49
+ tasks = []
50
+ for definition in PipelineDefinitionTaskDefinition.objects.filter(pipeline_definition=self, enabled=True).order_by('order'):
51
+ tasks.append(definition)
52
+
53
+ return tasks
54
+
55
+ def get_tasks_chain(self, context) -> list:
56
+ """
57
+ Get Tasks defined for this PipelineDefinition as a celery chain
58
+ """
59
+ PipelineDefinitionTaskDefinition = apps.get_model('django_eventspipe.PipelineDefinitionTaskDefinition')
60
+
61
+ chain = []
62
+ first = True
63
+
64
+ for task_function in PipelineDefinitionTaskDefinition.objects.filter(
65
+ pipeline_definition=self,
66
+ enabled=True
67
+ ).order_by('order').values_list('task_definition__function'):
68
+
69
+ if first:
70
+ signature = import_string(task_function[0]).s(context)
71
+ first = False
72
+ else:
73
+ signature = import_string(task_function[0]).s()
74
+
75
+ chain.append(signature)
76
+
77
+ return chain
@@ -0,0 +1,8 @@
1
+ from django.db import models
2
+
3
+ class PipelineDefinitionTaskDefinition(models.Model):
4
+ pipeline_definition = models.ForeignKey('django_eventspipe.PipelineDefinition', on_delete=models.CASCADE)
5
+ task_definition = models.ForeignKey('django_eventspipe.TaskDefinition', on_delete=models.CASCADE)
6
+ enabled = models.BooleanField(default=True)
7
+ order = models.IntegerField(default=20)
8
+
@@ -0,0 +1,86 @@
1
+ from django.db import models
2
+ from django.utils import timezone
3
+
4
+ class Task(models.Model):
5
+
6
+ STATUS_CHOICES = [
7
+ (0, "running"),
8
+ (1, "success"),
9
+ (2, "error"),
10
+ (3, "queued"),
11
+ (4, "skipped")
12
+ ]
13
+
14
+ status = models.IntegerField(default=2, choices=STATUS_CHOICES)
15
+ node = models.CharField(max_length=256, default="undefined")
16
+ pipeline = models.ForeignKey('django_eventspipe.Pipeline', on_delete=models.CASCADE)
17
+ definition = models.ForeignKey('django_eventspipe.PipelineDefinitionTaskDefinition', null=True, on_delete=models.SET_NULL)
18
+ start_ts = models.DateTimeField(blank=True, null=True)
19
+ end_ts = models.DateTimeField(blank=True, null=True)
20
+
21
+ @classmethod
22
+ def create_tasks(cls, pipeline: object) -> list:
23
+ """
24
+ Create `Task` for a given pipeline
25
+ """
26
+ tasks = []
27
+
28
+ for defined_task in pipeline.definition.get_defined_tasks():
29
+ # Create Task
30
+ task = cls(
31
+ status = 3,
32
+ pipeline = pipeline,
33
+ definition = defined_task
34
+ )
35
+ task.save()
36
+ tasks.append(task)
37
+
38
+ return tasks
39
+
40
+ @classmethod
41
+ def pipeline_failed(cls, pipeline: object) -> None:
42
+ """
43
+ Set all queued `Task` as failed.
44
+ """
45
+ tasks = cls.objects.all().filter(status=3, pipeline=pipeline) # kill both queued and running
46
+
47
+ for task in tasks:
48
+ task.status = 2
49
+ task.save()
50
+
51
+ def tracking_start(self, node:str) -> None:
52
+ """
53
+ Start tracking a `Task`
54
+ """
55
+ self.status = 0
56
+ self.node = node
57
+ self.start_ts = timezone.now()
58
+ self.save()
59
+
60
+ # Increase Pipeline's current_task
61
+ self.pipeline.current_task += 1
62
+
63
+ # Set Pipeline's status as running
64
+ if self.pipeline.status != 0:
65
+ self.pipeline.status = 0
66
+
67
+ self.pipeline.save()
68
+
69
+ def tracking_update(self, status: int) -> None:
70
+ """
71
+ Update `Task`'s tracking data
72
+ """
73
+ # Update this object
74
+ self.status = status
75
+ self.end_ts = timezone.now()
76
+ self.save()
77
+
78
+ if status == 1:
79
+ # Check if pipeline is completed
80
+ if self.pipeline.current_task == self.pipeline.tasks_count:
81
+ self.pipeline.status = status
82
+ self.pipeline.end_ts = timezone.now()
83
+ self.pipeline.save()
84
+ else:
85
+ # Set pipeline and all queued Tasks as failed
86
+ self.pipeline.fail()
@@ -0,0 +1,40 @@
1
+ from django.db import models
2
+ from django.utils.module_loading import import_string
3
+
4
+ class TaskDefinition(models.Model):
5
+ function = models.CharField(max_length=512, unique=True)
6
+ description = models.TextField(null=True, blank=True)
7
+
8
+ @classmethod
9
+ def check_definitions(cls, tasks: list) -> None:
10
+ """
11
+ Automatically import `Task` objects from celery
12
+ """
13
+ clean_tasks = []
14
+
15
+ # skip celery stuff
16
+ for task in tasks:
17
+ if "celery." in task: continue
18
+ clean_tasks.append(task)
19
+
20
+ # remove uknown `TaskDefinition`
21
+ for taskdef in cls.objects.all():
22
+ if taskdef.function not in clean_tasks:
23
+ taskdef.delete()
24
+
25
+ # import registered `Task` objects
26
+ for task in clean_tasks:
27
+ try:
28
+ imported_task = import_string(task)
29
+
30
+ if not cls.objects.filter(function=task).exists():
31
+ # Get `Task` function and documentation
32
+ new_task_def = cls(
33
+ function=task,
34
+ description=imported_task.__doc__
35
+ )
36
+ new_task_def.save()
37
+ except: pass
38
+
39
+ def __str__(self) -> str:
40
+ return "%s" % self.function
@@ -0,0 +1,15 @@
1
+ from django.dispatch import Signal, receiver
2
+ from .models import Pipeline
3
+
4
+ event_signal = Signal()
5
+
6
+ @receiver(event_signal)
7
+ def create_pipelines(
8
+ sender: object,
9
+ event: dict[str, object],
10
+ **kwargs
11
+ ) -> None:
12
+ """
13
+ Signal handler to create `Pipeline` objects from an event
14
+ """
15
+ Pipeline.new_from_event(event)
@@ -0,0 +1,3 @@
1
+ from django.test import TestCase
2
+
3
+ # Create your tests here.
@@ -0,0 +1,7 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ urlpatterns = [
6
+ path('artifact/<int:artifact_id>', views.get_artifact, name="get_artifact"),
7
+ ]
@@ -0,0 +1,38 @@
1
+ import json
2
+
3
+ from django.urls import reverse
4
+ from django.utils.html import format_html
5
+ from django.contrib.auth import get_user_model
6
+ from django.contrib.auth.models import User
7
+
8
+ def get_sentinel_user() -> User:
9
+ """
10
+ Prevent pipeline removal when deleting an user
11
+ """
12
+ return get_user_model().objects.get_or_create(username="deleted")[0]
13
+
14
+ # https://stackoverflow.com/a/53092940
15
+ def linkify(field_name: str) -> str:
16
+ """
17
+ Converts a foreign key value into clickable links.
18
+
19
+ If field_name is 'parent', link text will be str(obj.parent)
20
+ Link will be admin url for the admin url for obj.parent.id:change
21
+ """
22
+ def _linkify(obj):
23
+ linked_obj = getattr(obj, field_name)
24
+ if linked_obj is None:
25
+ return '-'
26
+ app_label = linked_obj._meta.app_label
27
+ model_name = linked_obj._meta.model_name
28
+ view_name = f'admin:{app_label}_{model_name}_change'
29
+ link_url = reverse(view_name, args=[linked_obj.pk])
30
+ return format_html('<a href="{}">{}</a>', link_url, linked_obj)
31
+
32
+ _linkify.short_description = field_name # Sets column name
33
+ return _linkify
34
+
35
+ # https://stackoverflow.com/a/72256767
36
+ class PrettyJSONEncoder(json.JSONEncoder):
37
+ def __init__(self, *args, indent: int, sort_keys: bool, **kwargs):
38
+ super().__init__(*args, indent=2, sort_keys=True, **kwargs)
@@ -0,0 +1,16 @@
1
+ from django.http import HttpRequest, HttpResponse
2
+ from django.contrib.auth.decorators import login_required
3
+
4
+ from .models import Artifact
5
+
6
+ @login_required
7
+ def get_artifact(request: HttpRequest, artifact_id: int) -> HttpResponse:
8
+ """
9
+ Get a file from Database
10
+ """
11
+ file = Artifact.objects.get(pk=artifact_id)
12
+
13
+ response = HttpResponse(file.data, content_type='application/octet-stream')
14
+ response['Content-Disposition']='attachment;filename=%s' % file.name
15
+
16
+ return response