19
+            model_name='product',
20
+            name='total',
21
+            field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True),
22
+        ),
23
+        migrations.AddField(
24
+            model_name='product',
25
+            name='vat',
26
+            field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True),
27
+        ),
28
+        migrations.AlterField(
29
+            model_name='product',
30
+            name='price',
31
+            field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True),
32
+        ),
33
+    ]

+ 40 - 0
app/fruit/migrations/0017_auto_20210807_1250.py

@@ -0,0 +1,40 @@
1
+# Generated by Django 3.2.6 on 2021-08-07 05:50
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('fruit', '0016_auto_20210807_1233'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.RemoveField(
14
+            model_name='product',
15
+            name='sub_total',
16
+        ),
17
+        migrations.RemoveField(
18
+            model_name='product',
19
+            name='total',
20
+        ),
21
+        migrations.RemoveField(
22
+            model_name='product',
23
+            name='vat',
24
+        ),
25
+        migrations.AddField(
26
+            model_name='sale',
27
+            name='sub_total',
28
+            field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True),
29
+        ),
30
+        migrations.AddField(
31
+            model_name='sale',
32
+            name='total',
33
+            field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True),
34
+        ),
35
+        migrations.AddField(
36
+            model_name='sale',
37
+            name='vat',
38
+            field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True),
39
+        ),
40
+    ]

+ 37 - 0
app/fruit/migrations/0018_inbox.py

@@ -0,0 +1,37 @@
1
+# Generated by Django 3.2.6 on 2021-08-07 08:21
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+import django.db.models.deletion
6
+
7
+
8
+class Migration(migrations.Migration):
9
+
10
+    dependencies = [
11
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12
+        ('fruit', '0017_auto_20210807_1250'),
13
+    ]
14
+
15
+    operations = [
16
+        migrations.CreateModel(
17
+            name='Inbox',
18
+            fields=[
19
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20
+                ('created_at', models.DateTimeField(auto_now_add=True, null=True)),
21
+                ('updated_at', models.DateTimeField(auto_now=True)),
22
+                ('subject', models.CharField(max_length=200)),
23
+                ('body', models.TextField()),
24
+                ('tel', models.CharField(blank=True, max_length=100, null=True)),
25
+                ('line_id', models.CharField(blank=True, max_length=100, null=True)),
26
+                ('email', models.EmailField(blank=True, max_length=254, null=True)),
27
+                ('buyer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='fruit.buyer')),
28
+                ('created_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_created', to=settings.AUTH_USER_MODEL)),
29
+                ('modified_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_modified', to=settings.AUTH_USER_MODEL)),
30
+                ('product', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='fruit.product')),
31
+                ('store', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='fruit.store')),
32
+            ],
33
+            options={
34
+                'abstract': False,
35
+            },
36
+        ),
37
+    ]

+ 18 - 0
app/fruit/migrations/0019_inbox_status.py

@@ -0,0 +1,18 @@
1
+# Generated by Django 3.2.6 on 2021-08-07 08:27
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('fruit', '0018_inbox'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AddField(
14
+            model_name='inbox',
15
+            name='status',
16
+            field=models.CharField(choices=[('request', 'Request'), ('read', 'Read'), ('process', 'Process'), ('complete', 'Complete')], default='request', max_length=30),
17
+        ),
18
+    ]

+ 67 - 0
app/fruit/migrations/0020_purchase_vendor.py

@@ -0,0 +1,67 @@
1
+# Generated by Django 3.2.6 on 2021-08-07 16:05
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+import django.db.models.deletion
6
+import django_google_maps.fields
7
+
8
+
9
+class Migration(migrations.Migration):
10
+
11
+    dependencies = [
12
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+        ('fruit', '0019_inbox_status'),
14
+    ]
15
+
16
+    operations = [
17
+        migrations.CreateModel(
18
+            name='Vendor',
19
+            fields=[
20
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21
+                ('created_at', models.DateTimeField(auto_now_add=True, null=True)),
22
+                ('updated_at', models.DateTimeField(auto_now=True)),
23
+                ('name', models.CharField(max_length=200)),
24
+                ('code', models.CharField(max_length=200)),
25
+                ('description', models.TextField(blank=True, null=True)),
26
+                ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
27
+                ('details', models.JSONField(blank=True, null=True)),
28
+                ('n_unit', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
29
+                ('unit_name', models.CharField(max_length=200, null=True)),
30
+                ('tel', models.CharField(blank=True, max_length=100, null=True)),
31
+                ('line_id', models.CharField(blank=True, max_length=100, null=True)),
32
+                ('email', models.EmailField(blank=True, max_length=254, null=True)),
33
+                ('address_text', models.TextField()),
34
+                ('address', django_google_maps.fields.AddressField(max_length=200)),
35
+                ('geolocation', django_google_maps.fields.GeoLocationField(max_length=100)),
36
+                ('created_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vendor_created', to=settings.AUTH_USER_MODEL)),
37
+                ('modified_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vendor_modified', to=settings.AUTH_USER_MODEL)),
38
+                ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fruit.product')),
39
+                ('store', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='fruit.store')),
40
+            ],
41
+            options={
42
+                'abstract': False,
43
+            },
44
+        ),
45
+        migrations.CreateModel(
46
+            name='Purchase',
47
+            fields=[
48
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
49
+                ('created_at', models.DateTimeField(auto_now_add=True, null=True)),
50
+                ('updated_at', models.DateTimeField(auto_now=True)),
51
+                ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
52
+                ('n_unit', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
53
+                ('unit_name', models.CharField(max_length=200, null=True)),
54
+                ('sub_total', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
55
+                ('vat', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
56
+                ('total', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
57
+                ('created_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_created', to=settings.AUTH_USER_MODEL)),
58
+                ('modified_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_modified', to=settings.AUTH_USER_MODEL)),
59
+                ('product', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='fruit.product')),
60
+                ('store', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='fruit.store')),
61
+                ('vendor', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='fruit.vendor')),
62
+            ],
63
+            options={
64
+                'abstract': False,
65
+            },
66
+        ),
67
+    ]

+ 112 - 3
app/fruit/models.py

@@ -20,10 +20,11 @@ from django.contrib.auth.models import User
20 20
 from django.db.models.signals import post_save
21 21
 from django.dispatch import receiver
22 22
 from shaqfindbed.utils import get_current_user
23
-
24
-
23
+import decimal
25 24
 # Create your models here.
26 25
 
26
+VAT = 0.07
27
+
27 28
 class GenericModel(models.Model):
28 29
     created_at = models.DateTimeField(auto_now_add=True, null=True)
29 30
     updated_at = models.DateTimeField(auto_now=True)
@@ -90,22 +91,126 @@ class ProductType(MPTTModel):
90 91
         return f"{self.name}"
91 92
 
92 93
 
94
+class Buyer(GenericModel, models.Model):
95
+    name = models.CharField(max_length=300, null=False, blank=False)
96
+    address_text = models.TextField(blank=False, null=False)
97
+
98
+    address = map_fields.AddressField(max_length=200)
99
+    geolocation = map_fields.GeoLocationField(max_length=100)
100
+
101
+
102
+    tel = models.CharField(max_length=100, null=False, blank=False)
103
+    line_id = models.CharField(max_length=100, null=True, blank=True)
104
+    email = models.EmailField(null=True, blank=True)
105
+
106
+
107
+    def __str__(self):
108
+        return f"{self.name} Tel.:{self.tel} Email:{self.email}"
109
+
110
+class Inbox(GenericModel, models.Model):
111
+    store = models.ForeignKey('Store', on_delete=models.DO_NOTHING, null=False, blank=False)
112
+    product = models.ForeignKey('Product', on_delete=models.DO_NOTHING, null=False, blank=False)
113
+    buyer = models.ForeignKey('Buyer', on_delete=models.DO_NOTHING, null=True, blank=True)
114
+
115
+    subject = models.CharField(max_length=200)
116
+    body = models.TextField(blank=False)
117
+
118
+    tel = models.CharField(max_length=100, null=True, blank=True)
119
+    line_id = models.CharField(max_length=100, null=True, blank=True)
120
+    email = models.EmailField(null=True, blank=True)
121
+
122
+
123
+    status = models.CharField(
124
+        max_length=30,
125
+        choices=(("request", "Request"), ("read", "Read"), ("process", "Process"), ("complete", "Complete")),
126
+        default="request",
127
+    )
128
+
129
+class Purchase(GenericModel, models.Model):
130
+    product = models.ForeignKey('Product', on_delete=models.DO_NOTHING, null=False, blank=False)
131
+    store = models.ForeignKey('Store', on_delete=models.DO_NOTHING, null=False, blank=False)
132
+    vendor = models.ForeignKey('Vendor', on_delete=models.DO_NOTHING, null=False, blank=False)
133
+
134
+    price = models.DecimalField(null=True, blank=True, decimal_places=2, max_digits=7)
135
+    n_unit = models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)
136
+    unit_name = models.CharField(max_length=200, null=True)
137
+
138
+
139
+    sub_total = models.DecimalField(null=True, blank=True, decimal_places=2, max_digits=10)
140
+    vat = models.DecimalField(null=True, blank=True, decimal_places=2, max_digits=10)
141
+    total = models.DecimalField(null=True, blank=True, decimal_places=2, max_digits=10)
142
+
143
+    def save(self, *args, **kwargs):
144
+        self.sub_total = self.price * self.n_unit
145
+        self.vat  = self.sub_total * decimal.Decimal(VAT)
146
+        self.total = self.sub_total + self.vat
147
+        super(Purchase, self).save(*args, **kwargs)
148
+
149
+class Sale(GenericModel, models.Model):
150
+    product = models.ForeignKey('Product', on_delete=models.DO_NOTHING, null=False, blank=False)
151
+    store = models.ForeignKey('Store', on_delete=models.DO_NOTHING, null=False, blank=False)
152
+    sku = models.ForeignKey('ProductSKU', on_delete=models.DO_NOTHING, null=False, blank=False)
153
+    price = models.DecimalField(null=True, blank=True, decimal_places=2, max_digits=7)
154
+    n_unit = models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)
155
+    unit_name = models.CharField(max_length=200, null=True)
156
+    buyer = models.ForeignKey('Buyer', on_delete=models.DO_NOTHING, null=False, blank=False)
157
+
158
+
159
+    sub_total = models.DecimalField(null=True, blank=True, decimal_places=2, max_digits=10)
160
+    vat = models.DecimalField(null=True, blank=True, decimal_places=2, max_digits=10)
161
+    total = models.DecimalField(null=True, blank=True, decimal_places=2, max_digits=10)
162
+
163
+    def save(self, *args, **kwargs):
164
+        self.sub_total = self.price * self.n_unit
165
+        self.vat  = self.sub_total * decimal.Decimal(VAT)
166
+        self.total = self.sub_total + self.vat
167
+        super(Sale, self).save(*args, **kwargs)
168
+
93 169
 class Product(GenericModel, models.Model ):
94 170
     name = models.CharField(max_length=200)
95 171
     code = models.CharField(max_length=200)
96 172
     product_type = TreeForeignKey('ProductType', on_delete=models.SET_NULL, null=True)
97 173
     description = models.TextField(blank=True, null=True)
98 174
     store = models.ForeignKey('Store', on_delete=models.CASCADE, null=True, blank=False)
99
-    price = models.DecimalField(null=True, blank=True, decimal_places=2, max_digits=7)
175
+    price = models.DecimalField(null=True, blank=True, decimal_places=2, max_digits=10)
176
+
177
+
178
+    details  = models.JSONField(null=True, blank=True)
179
+
180
+    n_unit = models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)
181
+    unit_name = models.CharField(max_length=200, null=True)
182
+
183
+    def __str__(self):
184
+        return f"{self.name} {self.code}"
185
+
186
+class Vendor(GenericModel, models.Model ):
187
+    name = models.CharField(max_length=200)
188
+    code = models.CharField(max_length=200)
189
+    #product_type = TreeForeignKey('ProductType', on_delete=models.SET_NULL, null=True)
190
+    product = models.ForeignKey('Product', on_delete=models.CASCADE)
191
+    description = models.TextField(blank=True, null=True)
192
+    store = models.ForeignKey('Store', on_delete=models.CASCADE, null=True, blank=False)
193
+    price = models.DecimalField(null=True, blank=True, decimal_places=2, max_digits=10)
194
+
100 195
 
101 196
     details  = models.JSONField(null=True, blank=True)
102 197
 
103 198
     n_unit = models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)
104 199
     unit_name = models.CharField(max_length=200, null=True)
105 200
 
201
+    tel = models.CharField(max_length=100, null=True, blank=True)
202
+    line_id = models.CharField(max_length=100, null=True, blank=True)
203
+    email = models.EmailField(null=True, blank=True)
204
+
205
+    address_text = models.TextField(blank=False, null=False)
206
+    address = map_fields.AddressField(max_length=200)
207
+    geolocation = map_fields.GeoLocationField(max_length=100)
208
+
209
+
106 210
     def __str__(self):
107 211
         return f"{self.name} {self.code}"
108 212
 
213
+
109 214
 class ProductSKU(GenericModel, models.Model ):
110 215
     sku = models.CharField(max_length=200)
111 216
     product  = models.ForeignKey('Product', on_delete=models.CASCADE, null=True)
@@ -117,8 +222,12 @@ class ProductSKU(GenericModel, models.Model ):
117 222
     n_unit = models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)
118 223
     unit_name = models.CharField(max_length=200, null=True)
119 224
 
225
+    def __str__(self):
226
+        return f"{self.sku} {self.product} @{self.price}(THB)"
227
+
120 228
 class Photo(GenericModel, models.Model):
121 229
     name = models.CharField(max_length=200,  blank=True)
230
+    order_n = models.IntegerField(default=0, blank=True)
122 231
     photo  = models.ImageField(upload_to="uploads/%Y/%m/%d/", blank=False, verbose_name="Photo")
123 232
     product = models.ForeignKey('Product', on_delete=models.CASCADE, null=True)
124 233
 

+ 17 - 0
app/fruit/templates/fruit/_paging.html

@@ -0,0 +1,17 @@
1
+<div class="pagination">
2
+    <span class="step-links">
3
+        {% if page_obj.has_previous %}
4
+            <a href="?page=1">&laquo; first</a>
5
+            <a href="?page={{ page_obj.previous_page_number }}">previous</a>
6
+        {% endif %}
7
+
8
+        <span class="current">
9
+            Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
10
+        </span>
11
+
12
+        {% if page_obj.has_next %}
13
+            <a href="?page={{ page_obj.next_page_number }}">next</a>
14
+            <a href="?page={{ page_obj.paginator.num_pages }}">last &raquo;</a>
15
+        {% endif %}
16
+    </span>
17
+</div>

+ 13 - 0
app/fruit/templates/fruit/_searchcenter.html

@@ -0,0 +1,13 @@
1
+{% load crispy_forms_tags %}
2
+<form method="get">
3
+    <div class='row'>
4
+        <!--
5
+            {{ filter.form.fields }} -->
6
+    {% for f0  in filter.form %}
7
+        <div class='col-md-3'>
8
+            {{ f0 | as_crispy_field}}  
9
+        </div>
10
+    {% endfor %}
11
+    </div>
12
+    <input type="submit" class='btn btn-primary' value='Search'/>
13
+</form>

+ 25 - 0
app/fruit/templates/fruit/inbox_form.html

@@ -0,0 +1,25 @@
1
+{% extends "fruit/inbox_index.html" %}
2
+{% load static %}
3
+{% load crispy_forms_tags %}
4
+{% load django_bootstrap_breadcrumbs %}
5
+
6
+{% block header %}            
7
+{{ form.media  }}
8
+{% endblock %}
9
+
10
+{% block breadcrumbs %}
11
+    {{ block.super }}
12
+    {% breadcrumb "Inbox Form" "fruit:inbox_edit" form.instance.pk %}
13
+{% endblock %}
14
+
15
+{% block store_main %}
16
+<h2>Inbox Form</h2>
17
+<form  method="post" enctype="multipart/form-data">
18
+    {% csrf_token %}
19
+    {{ form | crispy  }}
20
+    <br>
21
+    <input type='submit' class='btn btn-primary' value="Update" />
22
+
23
+</form>
24
+
25
+{% endblock %}

+ 50 - 0
app/fruit/templates/fruit/inbox_index.html

@@ -0,0 +1,50 @@
1
+{% extends "fruit/mystore.html" %}
2
+{% load static %}
3
+{% load crispy_forms_tags %}
4
+
5
+{% block header %}            
6
+{% endblock %}
7
+
8
+{% load django_bootstrap_breadcrumbs %}
9
+{% block breadcrumbs %}
10
+    {{ block.super }}
11
+    {% breadcrumb "Inboxes" "fruit:inbox_index" %}
12
+{% endblock %}
13
+
14
+{% block store_main %}
15
+<!--
16
+<a  class='btn btn-primary' href="{% url "fruit:product_create" %}">Create Product</a> -->
17
+<hr>
18
+<h2>
19
+    Inbox Index</h2>
20
+{% include "fruit/_searchcenter.html" %}
21
+<hr>
22
+<table class='table table-borded table-striped'>
23
+    <thead>
24
+        <tr>
25
+            <th>ID</th>
26
+            <th>Store</th>
27
+            <th>Product</th>
28
+            <th>Buyer</th>
29
+            <th>Subject</th>
30
+            <th>Body</th>
31
+            <th>Status</th>
32
+            <th>Created At</th></tr>
33
+    </thead>
34
+    <tbody>
35
+{% for p in page_obj %}
36
+        <tr>
37
+            <td><a href="{% url "fruit:inbox_edit" pk=p.pk %}">{{ p.id }}</a></td><td>{{ p.product }}</td>
38
+            <td>{{ p.store}}</td>
39
+            <td>{{ p.product }}</td>
40
+            <td>{{ p.buyer }}</td>
41
+            <td>{{ p.subject }}</td>
42
+            <td>{{ p.body }}</td>
43
+            <td>{{ p.status }}</td>
44
+            <td>{{ p.created_at }}</td>
45
+        </tr>
46
+{% endfor %}
47
+    </tbody>
48
+</table>
49
+{% include "fruit/_paging.html" %}
50
+{% endblock %}

+ 12 - 3
app/fruit/templates/fruit/mystore.html

@@ -1,6 +1,7 @@
1 1
 {% extends "front/base.html" %}
2 2
 {% load static %}
3 3
 {% load crispy_forms_tags %}
4
+{% load django_bootstrap_breadcrumbs %}
4 5
 
5 6
 {% block header %}            
6 7
 {{ storeForm.media  }}
@@ -8,21 +9,29 @@
8 9
 
9 10
 {% block content %}
10 11
 <h1>My Store</h1>
12
+<!-- 
11 13
 <form method=post>
12 14
     {% csrf_token %}
13 15
     <input type='text' name='name' class='form-control' placeholder='Store Name'/>
14 16
     <input type='submit' class='btn btn-primary' name='createStore' value="Create Store" />
15 17
 </form>
16
-<hr>
18
+<hr> -->
17 19
 <div class="d-flex align-items-start">
18 20
   <div class="nav flex-column nav-pills me-3 col-md-3 col-lg-2" id="v-pills-tab" role="tablist" aria-orientation="vertical">
19 21
       <a class="nav-link {{ mystore|yesno:"active," }}" id="v-pills-home-tab"  href="{% url "fruit:mystore" %}" type="button" role="tab" aria-controls="v-pills-home" aria-selected="true">Info</a>
20 22
       <a class="nav-link {{ product|yesno:"active," }}" id="v-pills-profile-tab"  href="{% url "fruit:product_index" %}" type="button" role="tab" aria-controls="v-pills-profile" aria-selected="false">Products</a>
21
-    <a class="nav-link" id="v-pills-messages-tab" data-toggle="pill" data-bs-target="#v-pills-messages" type="button" role="tab" aria-controls="v-pills-messages" aria-selected="false">Inbox</a>
22
-    <a class="nav-link" id="v-pills-settings-tab" data-toggle="pill" data-bs-target="#v-pills-settings" type="button" role="tab" aria-controls="v-pills-settings" aria-selected="false">Saled Items</a>
23
+    <a class="nav-link" id="v-pills-messages-tab" href="{% url "fruit:inbox_index" %}" type="button" role="tab" aria-controls="v-pills-messages" aria-selected="false">Inbox</a>
24
+    <a class="nav-link {{ sale_active|yesno:"active," }}" id="v-pills-settings-tab" href="{% url "fruit:sale_index" %}"  type="button" role="tab" aria-controls="v-pills-settings" aria-selected="false">Saled Items</a>
23 25
     <a class="nav-link" id="v-pills-vendor-tab" data-toggle="pill" data-bs-target="#v-pills-vendor" type="button" role="tab" aria-controls="v-pills-vendor" aria-selected="false">Vendors</a>
24 26
   </div>
25 27
   <div class="tab-content p-3 col-md-9 col-lg-10" id="v-pills-tabContent">
28
+      {% block breadcrumbs %}
29
+          {% clear_breadcrumbs %}
30
+          {% breadcrumb "My Store" "fruit:mystore" %}
31
+      {% endblock %}
32
+
33
+
34
+    {% render_breadcrumbs %} 
26 35
       {% block store_main %}
27 36
       {% endblock %}
28 37
   </div>

+ 36 - 30
app/fruit/templates/fruit/product_form.html

@@ -1,4 +1,4 @@
1
-{% extends "fruit/mystore.html" %}
1
+{% extends "fruit/product_index.html" %}
2 2
 {% load static %}
3 3
 {% load crispy_forms_tags %}
4 4
 
@@ -7,41 +7,47 @@
7 7
 {{ form2.media  }}
8 8
 {% endblock %}
9 9
 
10
+{% load django_bootstrap_breadcrumbs %}
11
+{% block breadcrumbs %}
12
+    {{ block.super }}
13
+    {% breadcrumb "Product Edit" "fruit:product_edit" pid  %}
14
+{% endblock %}
15
+
10 16
 {% block store_main %}
11 17
     Create Product
12 18
 
19
+
13 20
 <form  method="post" enctype="multipart/form-data">
14 21
     {% csrf_token %}
15 22
     {{ form | crispy  }}
16
-    <fieldset class="border p-2">
17
-        <legend class='w-auto d-inline-block form-legend p-3'>Photos</legend>
18
-        {{ form2.management_form }}
19
-        <div class='row'>
20
-        {% for f0  in form2 %}
21
-        <div class='col-md-3'>
22
-        <!-- {{ f0.fields }} -->
23
-
24
-            {{ f0.id  | as_crispy_field }}
25
-            {{ f0.name | as_crispy_field }}
26
-            {% if f0.instance.photo %}
27
-            <a href="{{ f0.instance.photo.url }}" target="_blank">
28
-                <img src="{{ f0.instance.photo.url  }}" width='100%' style='max-height:200px'></a>
29
-            {% endif %}
30
-            {{ f0.photo | as_crispy_field }}
31
-            
32
-            {{ f0.product | as_crispy_field }}
33
-            {{ f0.DELETE | as_crispy_field }}
34
-        </div>
35
-        {% endfor %}
36
-        </div>
37
-    </fieldset>
38
-    <br>
39
-    <input type='submit' class='btn btn-primary' name='updateStore' value="Update" />
23
+        <fieldset class="border p-2 row">
24
+            <legend class='w-auto d-inline-block form-legend p-3'>Photos</legend>
25
+            {{ form2.management_form }}
26
+            {% for f0  in form2 %}
27
+            <div class='col-md-3 border'>
28
+            <!-- {{ f0.fields }} -->
29
+
30
+                {{ f0.id  | as_crispy_field }}
31
+                {{ f0.name | as_crispy_field }}
32
+                {% if f0.instance.photo %}
33
+                <a href="{{ f0.instance.photo.url }}" target="_blank">
34
+                    <img src="{{ f0.instance.photo.url  }}" width='100%' style='max-height:200px'></a>
35
+                {% endif %}
36
+                {{ f0.photo | as_crispy_field }}
37
+                
38
+                {{ f0.order_n  | as_crispy_field }}
39
+                {{ f0.product | as_crispy_field }}
40
+                {{ f0.DELETE | as_crispy_field }}
41
+            </div>
42
+            {% endfor %}
43
+        </fieldset>
44
+        <br>
45
+        <input type='submit' class='btn btn-primary' name='updateStore' value="Update" />
40 46
 
41
-</form>
42
-<hr>
43
-<h2>SKUs</h2>
44
-<a href="{% url "fruit:create_sku" pk=object.pk %}" class='btn btn-primary'>Create SKU</a><br><br>
47
+    </form>
48
+    <hr>
49
+    <h2>SKUs</h2>
50
+    <a href="{% url "fruit:create_sku" pk=obj.pk %}" class='btn btn-primary'>Create SKU</a><br><br>
45 51
 
46 52
 <table class='table table-bordered  table-striped'>
47 53
     <thead>
@@ -50,7 +56,7 @@
50 56
     </thead>
51 57
     <tbody>
52 58
         
53
-        {% for o in  object.productsku_set.all %}
59
+        {% for o in  obj.productsku_set.all %}
54 60
             <tr>
55 61
                 <td><a href="{% url "fruit:edit_sku" pk=o.pk %}">{{ o.sku }}</a></td>
56 62
                 <td>{{ o.created_at }}</td>

+ 17 - 6
app/fruit/templates/fruit/product_index.html

@@ -5,21 +5,32 @@
5 5
 {% block header %}            
6 6
 {% endblock %}
7 7
 
8
+{% load django_bootstrap_breadcrumbs %}
9
+{% block breadcrumbs %}
10
+    {{ block.super }}
11
+    {% breadcrumb "Products" "fruit:product_index" %}
12
+{% endblock %}
13
+
8 14
 {% block store_main %}
9
-<a  href="{% url "fruit:product_create" %}">Create Product</a>
15
+<a  class='btn btn-primary' href="{% url "fruit:product_create" %}">Create Product</a>
16
+<hr>
17
+<h2>
18
+    Product Index</h2>
19
+{% include "fruit/_searchcenter.html" %}
10 20
 <hr>
11
-Product Index
12
-{{ products }}
21
+<!--
22
+    {{ products }} -->
13 23
 <table class='table table-borded table-striped'>
14 24
     <thead>
15
-        <tr><th>ID</th><th>Name</th><th>Created At</th></tr>
25
+        <tr><th>ID</th><th>Name</th><th>Price</th><th>Created At</th></tr>
16 26
     </thead>
17 27
     <tbody>
18
-{% for p in products %}
28
+{% for p in page_obj %}
19 29
         <tr>
20
-            <td><a href="{% url "fruit:product_edit" pk=p.pk %}">{{ p.id }}</a></td><td>{{ p.name }}</td><td>{{ p.created_at }}</td>
30
+            <td><a href="{% url "fruit:product_edit" pk=p.pk %}">{{ p.id }}</a></td><td>{{ p.name }}</td><td>{% firstof p.price  "-" %}</td><td>{{ p.created_at }}</td>
21 31
         </tr>
22 32
 {% endfor %}
23 33
     </tbody>
24 34
 </table>
35
+{% include "fruit/_paging.html" %}
25 36
 {% endblock %}

+ 25 - 0
app/fruit/templates/fruit/sale_form.html

@@ -0,0 +1,25 @@
1
+{% extends "fruit/sale_index.html" %}
2
+{% load static %}
3
+{% load crispy_forms_tags %}
4
+{% load django_bootstrap_breadcrumbs %}
5
+
6
+{% block header %}            
7
+{{ form.media  }}
8
+{% endblock %}
9
+
10
+{% block breadcrumbs %}
11
+    {{ block.super }}
12
+    {% breadcrumb "Sales Form" "fruit:sale_edit" form.instance.pk %}
13
+{% endblock %}
14
+
15
+{% block store_main %}
16
+<h2>Sale Form</h2>
17
+<form  method="post" enctype="multipart/form-data">
18
+    {% csrf_token %}
19
+    {{ form | crispy  }}
20
+    <br>
21
+    <input type='submit' class='btn btn-primary' value="Update" />
22
+
23
+</form>
24
+
25
+{% endblock %}

+ 41 - 0
app/fruit/templates/fruit/sale_index.html

@@ -0,0 +1,41 @@
1
+{% extends "fruit/mystore.html" %}
2
+{% load static %}
3
+{% load crispy_forms_tags %}
4
+
5
+{% block header %}            
6
+{% endblock %}
7
+
8
+{% load django_bootstrap_breadcrumbs %}
9
+{% block breadcrumbs %}
10
+    {{ block.super }}
11
+    {% breadcrumb "Sales" "fruit:sale_index" %}
12
+{% endblock %}
13
+
14
+{% block store_main %}
15
+<!--
16
+<a  class='btn btn-primary' href="{% url "fruit:product_create" %}">Create Product</a> -->
17
+<hr>
18
+<h2>
19
+    Sale Index</h2>
20
+{% include "fruit/_searchcenter.html" %}
21
+<hr>
22
+<table class='table table-borded table-striped'>
23
+    <thead>
24
+        <tr><th>ID</th><th>Product</th><th>SKU</th><th>Price</th><th>Unit(s)</th><th>UName</th><th>Total</th><th>Buyer</th><th>Created At</th></tr>
25
+    </thead>
26
+    <tbody>
27
+{% for p in page_obj %}
28
+        <tr>
29
+            <td><a href="{% url "fruit:sale_edit" pk=p.pk %}">{{ p.id }}</a></td><td>{{ p.product }}</td>
30
+            <td>{{ p.sku }}</td>
31
+            <td>{{ p.n_unit }}</td>
32
+            <td>{{ p.unit_name }}</td>
33
+            <td>{{ p.total }}</td>
34
+            <td>{{ p.buyer }}</td>
35
+            <td>{% firstof p.price  "-" %}</td><td>{{ p.created_at }}</td>
36
+        </tr>
37
+{% endfor %}
38
+    </tbody>
39
+</table>
40
+{% include "fruit/_paging.html" %}
41
+{% endblock %}

+ 7 - 1
app/fruit/templates/fruit/sku_form.html

@@ -1,4 +1,4 @@
1
-{% extends "fruit/mystore.html" %}
1
+{% extends "fruit/product_form.html" %}
2 2
 {% load static %}
3 3
 {% load crispy_forms_tags %}
4 4
 
@@ -6,6 +6,12 @@
6 6
 {{ form.media  }}
7 7
 {% endblock %}
8 8
 
9
+{% load django_bootstrap_breadcrumbs %}
10
+{% block breadcrumbs %}
11
+    {{ block.super }}
12
+    {% breadcrumb "SKU Edit" "fruit:sku_edit" pk=form.instance.pk  %}
13
+{% endblock %}
14
+
9 15
 {% block store_main %}
10 16
     SKU Form
11 17
 

+ 4 - 0
app/fruit/urls.py

@@ -11,6 +11,10 @@ urlpatterns = [
11 11
     path('sku/<pk>', views.edit_sku, name='edit_sku'),
12 12
     path('products/', views.product_index, name='product_index'),
13 13
     path('products/<pk>', views.product_edit, name='product_edit'),
14
+    path('sales/', views.sale_index, name='sale_index'),
15
+    path('sales/<pk>', views.sale_edit, name='sale_edit'),
16
+    path('inbox/', views.inbox_index, name='inbox_index'),
17
+    path('inbox/<pk>', views.inbox_edit, name='inbox_edit'),
14 18
     path('signup', views.signup, name='signup'),
15 19
 ]
16 20
 

+ 98 - 6
app/fruit/views.py

@@ -6,9 +6,10 @@ from django.contrib.auth.forms import UserCreationForm
6 6
 from django.urls import reverse
7 7
 from django.contrib.auth.decorators import login_required
8 8
 
9
-from fruit.models import Store, Product, Photo, ProductSKU
10
-from .forms import StoreForm, ProductForm, PhotoFormSet, InlinePhotoFormset, ProductSKUForm
9
+from fruit.models import Store, Product, Photo, ProductSKU, Sale, Inbox
10
+from .forms import StoreForm, ProductForm, InboxForm, SaleForm,  PhotoFormSet, InlinePhotoFormset, ProductSKUForm, ProductFilter, SaleFilter, InboxFilter
11 11
 from django.contrib import messages
12
+from django.core.paginator import Paginator
12 13
 
13 14
 
14 15
 def index(request):
@@ -48,7 +49,27 @@ def mystore(request):
48 49
 def product_index(request):
49 50
     stores = request.user.store_created.all().order_by("-created_at")
50 51
     products = stores[0].product_set.all().order_by("-created_at")
51
-    return render(request, 'fruit/product_index.html', {'products': products, 'product': True})
52
+
53
+    f = ProductFilter(request.GET, queryset=products)
54
+
55
+    paginator = Paginator(f.qs, 25)
56
+    page_number = request.GET.get('page')
57
+    page_obj = paginator.get_page(page_number)
58
+
59
+    return render(request, 'fruit/product_index.html', {'products': products, 'product': True, 'page_obj': page_obj, 'filter': f})
60
+
61
+@login_required
62
+def sale_index(request):
63
+    stores = request.user.store_created.all().order_by("-created_at")
64
+    o_qs = stores[0].sale_set.all().order_by("-created_at")
65
+
66
+    f = SaleFilter(request.GET, queryset=o_qs)
67
+
68
+    paginator = Paginator(f.qs, 25)
69
+    page_number = request.GET.get('page')
70
+    page_obj = paginator.get_page(page_number)
71
+
72
+    return render(request, 'fruit/sale_index.html', {'o_qs': o_qs, 'sale_active': True, 'page_obj': page_obj, 'filter': f})
52 73
 
53 74
 @login_required
54 75
 def create_product(request):
@@ -97,11 +118,14 @@ def create_sku(request, pk):
97 118
             message.error(request, "SKU  created failed")
98 119
             return redirect("fruit:create_sku", pk=int(pk))
99 120
 
100
-    return render(request, 'fruit/sku_form.html', {'form': form})
121
+    return render(request, 'fruit/sku_form.html', {'form': form, 'pid': p.pk})
101 122
 
102 123
 @login_required
103 124
 def edit_sku(request, pk):
125
+
104 126
     p = ProductSKU.objects.get(pk=pk)
127
+    p0 = p.product
128
+
105 129
     form = ProductSKUForm(instance=p)
106 130
     if request.method == "POST":
107 131
         form = ProductSKUForm(request.POST)
@@ -113,7 +137,7 @@ def edit_sku(request, pk):
113 137
             message.error(request, "SKU  created failed")
114 138
             return redirect("fruit:create_sku", pk=int(pk))
115 139
 
116
-    return render(request, 'fruit/sku_form.html', {'form': form})
140
+    return render(request, 'fruit/sku_form.html', {'form': form, 'pid': p0.pk})
117 141
 
118 142
 @login_required
119 143
 def product_edit(request, pk):
@@ -151,7 +175,75 @@ def product_edit(request, pk):
151 175
 
152 176
         return redirect("fruit:product_edit", pk =  int(pk))
153 177
 
154
-    return render(request, 'fruit/product_form.html', {'product': True, 'form': form, 'form2': form2, 'object': product })
178
+    return render(request, 'fruit/product_form.html', {'product': True, 'form': form, 'form2': form2, 'obj': product })
179
+
180
+@login_required
181
+def sale_edit(request, pk):
182
+    stores = request.user.store_created.all().order_by("-created_at")
183
+
184
+    obj = Sale.objects.get(pk=pk)
185
+    form = SaleForm(instance = obj)
186
+
187
+    if request.method == "POST":
188
+        form = SaleForm(request.POST)
189
+        if form.is_valid():
190
+            instance1 = form.save()
191
+            '''
192
+            print(instances)
193
+            for s in instances:
194
+                s.product = instance1
195
+                s.save()
196
+            '''
197
+            messages.success(request, "Sale Save")
198
+        else:
199
+            print("Invalid ")
200
+            if form.errors:
201
+                messages.error(request, form.errors)
202
+
203
+        return redirect("fruit:sale_edit", pk =  int(pk))
204
+
205
+    return render(request, 'fruit/sale_form.html', {'sale_active': True, 'form': form, 'object': obj})
206
+
207
+
208
+@login_required
209
+def inbox_index(request):
210
+    stores = request.user.store_created.all().order_by("-created_at")
211
+    o_qs = stores[0].inbox_set.all().order_by("-created_at")
212
+
213
+    f = InboxFilter(request.GET, queryset=o_qs)
214
+
215
+    paginator = Paginator(f.qs, 25)
216
+    page_number = request.GET.get('page')
217
+    page_obj = paginator.get_page(page_number)
218
+
219
+    return render(request, 'fruit/inbox_index.html', {'o_qs': o_qs, 'inbox_active': True, 'page_obj': page_obj, 'filter': f})
220
+
221
+@login_required
222
+def inbox_edit(request, pk):
223
+    stores = request.user.store_created.all().order_by("-created_at")
224
+
225
+    obj = Inbox.objects.get(pk=pk)
226
+    form = InboxForm(instance = obj)
227
+
228
+    if request.method == "POST":
229
+        form = InboxForm(request.POST)
230
+        if form.is_valid():
231
+            instance1 = form.save()
232
+            '''
233
+            print(instances)
234
+            for s in instances:
235
+                s.product = instance1
236
+                s.save()
237
+            '''
238
+            messages.success(request, "Sale Save")
239
+        else:
240
+            print("Invalid ")
241
+            if form.errors:
242
+                messages.error(request, form.errors)
243
+
244
+        return redirect("fruit:sale_edit", pk =  int(pk))
245
+
246
+    return render(request, 'fruit/inbox_form.html', {'inbox_active': True, 'form': form, 'object': obj})
155 247
 
156 248
 def signup(request):
157 249
     if request.method == 'POST':

+ 3 - 0
app/requirements.txt

@@ -14,3 +14,6 @@ Pillow
14 14
 django-quill-editor
15 15
 django-taggit
16 16
 django-crispy-forms
17
+django-filter
18
+django-paypal
19
+django-bootstrap-breadcrumbs

BIN
app/shaqfindbed/__pycache__/settings.cpython-39.pyc


+ 3 - 0
app/shaqfindbed/settings.py

@@ -47,12 +47,14 @@ INSTALLED_APPS = [
47 47
     'dal_select2',
48 48
     'mptt',
49 49
     'django_google_maps',
50
+    'django_bootstrap_breadcrumbs',
50 51
     'django.contrib.admin',
51 52
     'django.contrib.auth',
52 53
     'django.contrib.contenttypes',
53 54
     'django.contrib.sessions',
54 55
     'django.contrib.messages',
55 56
     'import_export',
57
+    'django_filters',
56 58
     'django_quill',
57 59
     'crispy_forms',
58 60
     'taggit',
@@ -171,3 +173,4 @@ LOGIN_REDIRECT_URL = '/fruit/mystore'
171 173
 LOGIN_URL = '/login'
172 174
 
173 175
 CRISPY_TEMPLATE_PACK = "bootstrap4"
176
+BREADCRUMBS_TEMPLATE = "django_bootstrap_breadcrumbs/bootstrap4.html"

tum/soc - Gogs: Simplico Git Service

Нема описа

test-ioc-pipeline.py 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. #!/usr/bin/env python3
  2. """
  3. test-ioc-pipeline.py — End-to-end test: Wazuh syslog → soc-integrator IOC evaluation → IRIS alert.
  4. Flow:
  5. 1. Send a syslog event with embedded IOC payload to Wazuh (via Docker exec)
  6. 2. Wait for Wazuh indexer to index the alert
  7. 3. Trigger POST /wazuh/sync-to-mvp
  8. 4. Verify ioc_evaluated > 0 and decision_source == "direct_api" (VT/AbuseIPDB, no Shuffle)
  9. 5. Verify IRIS alert created if IOC matched
  10. Log formats used (both fire level-8 rules):
  11. ioc_ips → FortiGate IPS: logid=0419016386 ... attack=IOC.IP.Match ioc_type=ip → rule 110318
  12. ioc_dns → soc_event=dns_ioc event_type=ioc_dns_traffic query=<domain> → rule 110301
  13. Usage:
  14. python3 scripts/test-ioc-pipeline.py
  15. python3 scripts/test-ioc-pipeline.py --ioc-type domain --ioc-value "evil.example.com"
  16. python3 scripts/test-ioc-pipeline.py --ioc-value "198.51.100.1" --wait 30
  17. python3 scripts/test-ioc-pipeline.py --no-send # sync only, skip sending
  18. Env vars:
  19. INTEGRATOR_URL default: http://localhost:8088
  20. INTEGRATOR_API_KEY default: dev-internal-key
  21. """
  22. from __future__ import annotations
  23. import argparse
  24. import datetime
  25. import json
  26. import os
  27. import socket
  28. import ssl
  29. import subprocess
  30. import sys
  31. import time
  32. import urllib.request
  33. import uuid
  34. # ---------------------------------------------------------------------------
  35. # Config
  36. # ---------------------------------------------------------------------------
  37. INTEGRATOR_URL = os.environ.get("INTEGRATOR_URL", "http://localhost:8088")
  38. INTEGRATOR_KEY = os.environ.get("INTEGRATOR_API_KEY", "dev-internal-key")
  39. WAZUH_CONTAINER = "wazuh-single-wazuh.manager-1"
  40. SYSLOG_PORT = 514
  41. SYSLOG_SRC_IP = "172.16.22.253" # must be in allowed-ips
  42. SSL_CTX = ssl.create_default_context()
  43. SSL_CTX.check_hostname = False
  44. SSL_CTX.verify_mode = ssl.CERT_NONE
  45. PASS = "\033[32m✓\033[0m"
  46. FAIL = "\033[31m✗\033[0m"
  47. INFO = "\033[36m·\033[0m"
  48. WARN = "\033[33m!\033[0m"
  49. # ---------------------------------------------------------------------------
  50. # HTTP helpers
  51. # ---------------------------------------------------------------------------
  52. def _get(url: str, headers: dict | None = None) -> dict:
  53. req = urllib.request.Request(url, headers=headers or {})
  54. with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as r:
  55. return json.loads(r.read())
  56. def _post(url: str, data: dict | None = None, headers: dict | None = None) -> dict:
  57. body = json.dumps(data or {}).encode()
  58. h = {"Content-Type": "application/json", **(headers or {})}
  59. req = urllib.request.Request(url, data=body, headers=h, method="POST")
  60. with urllib.request.urlopen(req, context=SSL_CTX, timeout=30) as r:
  61. return json.loads(r.read())
  62. def _integrator(path: str, method: str = "GET", data: dict | None = None, params: str = "") -> dict:
  63. url = f"{INTEGRATOR_URL}{path}"
  64. if params:
  65. url += ("&" if "?" in url else "?") + params
  66. headers = {"X-Internal-API-Key": INTEGRATOR_KEY}
  67. if method == "POST":
  68. return _post(url, data, headers)
  69. return _get(url, headers)
  70. # ---------------------------------------------------------------------------
  71. # Syslog helpers
  72. # ---------------------------------------------------------------------------
  73. def _ts() -> tuple[str, str]:
  74. now = datetime.datetime.now(datetime.timezone.utc)
  75. return now.strftime("%Y-%m-%d"), now.strftime("%H:%M:%S")
  76. def _send_via_docker(raw_line: str, port: int) -> bool:
  77. """Send raw syslog line from inside the Wazuh container via /dev/udp."""
  78. py_cmd = (
  79. f"import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); "
  80. f"s.sendto({repr((raw_line + chr(10)).encode())}, ('127.0.0.1',{port})); s.close()"
  81. )
  82. result = subprocess.run(
  83. ["docker", "exec", WAZUH_CONTAINER, "python3", "-c", py_cmd],
  84. capture_output=True, timeout=10,
  85. )
  86. return result.returncode == 0
  87. def build_ioc_ips_log(event_id: str, ioc_value: str) -> str:
  88. """
  89. FortiGate IPS format that fires rule 110318 (A2-08 IOC IP indicator, level 8).
  90. Requires: logid=0419016386 + attack field + action=dropped + ioc_type=ip
  91. """
  92. date, ts = _ts()
  93. return (
  94. f"date={date} time={ts} devname=FG-TEST-FW devid=FGT60E0000000001 "
  95. f"logid=0419016386 type=utm subtype=ips level=critical vd=root "
  96. f"srcip={ioc_value} srcport=54321 srcintf=port1 "
  97. f"dstip=172.16.1.5 dstport=80 dstintf=port2 "
  98. f"policyid=1 proto=6 action=dropped service=HTTP "
  99. f"attack=IOC.IP.Match ioc_type=ip ioc_value={ioc_value} "
  100. f"event_id={event_id}"
  101. )
  102. def build_ioc_dns_log(event_id: str, domain: str) -> str:
  103. """
  104. SOC DNS IOC format that fires rule 110301 (A1-01 DNS IOC traffic, level 8).
  105. Requires: soc_event=dns_ioc + event_type=ioc_dns_traffic
  106. """
  107. return (
  108. f"soc_event=dns_ioc event_type=ioc_dns_traffic "
  109. f"event_id={event_id} "
  110. f"src_ip=10.26.45.214 query={domain} "
  111. f"action=blocked severity=medium"
  112. )
  113. # ---------------------------------------------------------------------------
  114. # Pretty helpers
  115. # ---------------------------------------------------------------------------
  116. def step(n: int | str, label: str) -> None:
  117. print(f"\n\033[1mStep {n}: {label}\033[0m")
  118. def ok(msg: str) -> None:
  119. print(f" {PASS} {msg}")
  120. def fail(msg: str) -> None:
  121. print(f" {FAIL} {msg}")
  122. def info(msg: str) -> None:
  123. print(f" {INFO} {msg}")
  124. def warn(msg: str) -> None:
  125. print(f" {WARN} {msg}")
  126. # ---------------------------------------------------------------------------
  127. # Main test
  128. # ---------------------------------------------------------------------------
  129. def run(args: argparse.Namespace) -> int:
  130. errors = 0
  131. event_id = f"ioc-test-{uuid.uuid4().hex[:12]}"
  132. print(f"\n\033[1mIOC Pipeline Test\033[0m (Wazuh → soc-integrator → IRIS)")
  133. print(f" ioc_type : {args.ioc_type}")
  134. print(f" ioc_value : {args.ioc_value}")
  135. print(f" event_id : {event_id}")
  136. # ------------------------------------------------------------------
  137. # Step 0: Health check
  138. # ------------------------------------------------------------------
  139. step(0, "Health check")
  140. try:
  141. h = _get(f"{INTEGRATOR_URL}/health")
  142. if h.get("ok"):
  143. ok(f"soc-integrator reachable at {INTEGRATOR_URL}")
  144. else:
  145. fail(f"soc-integrator unhealthy: {h}")
  146. return 1
  147. except Exception as exc:
  148. fail(f"Cannot reach soc-integrator: {exc}")
  149. return 1
  150. # ------------------------------------------------------------------
  151. # Step 1: Snapshot IRIS alert count before
  152. # ------------------------------------------------------------------
  153. step(1, "Snapshot IRIS alert count before sync")
  154. max_id_before = 0
  155. try:
  156. resp = _get(f"{INTEGRATOR_URL}/iris/alerts?per_page=5&sort_by=alert_id&sort_dir=desc")
  157. alerts_before = (resp.get("data") or {}).get("alerts", {}).get("data", [])
  158. max_id_before = max((a["alert_id"] for a in alerts_before), default=0)
  159. ok(f"Latest alert_id before = {max_id_before}")
  160. except Exception as exc:
  161. warn(f"Could not snapshot IRIS alerts (verification skipped): {exc}")
  162. # ------------------------------------------------------------------
  163. # Step 2: Direct smoke test — /mvp/ioc/evaluate without Shuffle
  164. # ------------------------------------------------------------------
  165. step(2, "Direct /mvp/ioc/evaluate smoke test (confirm no Shuffle)")
  166. try:
  167. eval_resp = _integrator(
  168. "/mvp/ioc/evaluate", method="POST",
  169. data={"ioc_type": args.ioc_type, "ioc_value": args.ioc_value},
  170. )
  171. eval_data = eval_resp.get("data", {})
  172. decision_source = eval_data.get("decision_source", "")
  173. confidence = eval_data.get("confidence", 0.0)
  174. matched = eval_data.get("matched", False)
  175. severity = eval_data.get("severity", "")
  176. iocs = eval_data.get("iocs", [])
  177. info(f"decision_source : {decision_source}")
  178. info(f"matched : {matched} confidence={confidence:.4f} severity={severity}")
  179. if iocs:
  180. for ioc in iocs:
  181. info(f"ioc match : {ioc.get('reason','')[:80]}")
  182. if decision_source == "direct_api":
  183. ok("decision_source=direct_api — VT/AbuseIPDB path confirmed, no Shuffle")
  184. elif decision_source == "skipped":
  185. ok("IOC skipped (empty/placeholder value) — try a real IP for full VT result")
  186. else:
  187. fail(f"Unexpected decision_source={decision_source!r} — check evaluate_ioc()")
  188. errors += 1
  189. except Exception as exc:
  190. fail(f"/mvp/ioc/evaluate failed: {exc}")
  191. errors += 1
  192. # ------------------------------------------------------------------
  193. # Step 3: Send IOC syslog event to Wazuh
  194. # ------------------------------------------------------------------
  195. step(3, "Send IOC syslog event to Wazuh")
  196. sent_ok = False
  197. if args.no_send:
  198. info("Skipped (--no-send)")
  199. else:
  200. if args.ioc_type == "domain":
  201. log_msg = build_ioc_dns_log(event_id, args.ioc_value)
  202. rule_note = "rule 110301 (A1-01 DNS IOC, level 8)"
  203. else:
  204. log_msg = build_ioc_ips_log(event_id, args.ioc_value)
  205. rule_note = "rule 110318 (A2-08 FortiGate IPS IOC, level 8)"
  206. info(f"Format : {rule_note}")
  207. info(f"Log : {log_msg[:100]}...")
  208. sent_ok = _send_via_docker(log_msg, SYSLOG_PORT)
  209. if sent_ok:
  210. ok(f"Syslog sent via {WAZUH_CONTAINER} (event_id={event_id})")
  211. else:
  212. fail(f"Failed to send syslog — is {WAZUH_CONTAINER} running?")
  213. errors += 1
  214. # ------------------------------------------------------------------
  215. # Step 4: Wait for Wazuh indexer
  216. # ------------------------------------------------------------------
  217. step(4, f"Wait {args.wait}s for Wazuh indexer")
  218. if args.no_send:
  219. info("Skipped (--no-send)")
  220. else:
  221. for remaining in range(args.wait, 0, -5):
  222. print(f" {INFO} {remaining}s remaining...", end="\r", flush=True)
  223. time.sleep(min(5, remaining))
  224. print()
  225. ok("Wait complete")
  226. # ------------------------------------------------------------------
  227. # Step 5: Trigger sync
  228. # ------------------------------------------------------------------
  229. step(5, "Trigger Wazuh → IRIS sync")
  230. min_sev = args.min_severity
  231. params = f"limit=50&minutes={args.minutes}&q=*&min_severity={min_sev}"
  232. s: dict = {}
  233. try:
  234. resp = _integrator("/wazuh/sync-to-mvp", method="POST", params=params)
  235. s = resp["data"]["sync"]
  236. info(f"min_severity_applied : {s['min_severity_applied']}")
  237. info(f"processed : {s['processed']}")
  238. info(f"skipped_existing : {s['skipped_existing']}")
  239. info(f"skipped_filtered : {s.get('skipped_filtered', 0)}")
  240. info(f"ingested : {s['ingested']}")
  241. info(f"ioc_evaluated : {s.get('ioc_evaluated', 0)}")
  242. info(f"ioc_matched : {s.get('ioc_matched', 0)}")
  243. info(f"ioc_rejected : {s.get('ioc_rejected', 0)}")
  244. info(f"iris_alert_ids : {s['iris_alert_ids']}")
  245. if s.get("errors"):
  246. fail(f"Sync errors: {s['errors']}")
  247. errors += 1
  248. else:
  249. ok("Sync completed without errors")
  250. except Exception as exc:
  251. fail(f"Sync request failed: {exc}")
  252. return errors + 1
  253. # ------------------------------------------------------------------
  254. # Step 6: Verify IOC was evaluated
  255. # ------------------------------------------------------------------
  256. step(6, "Verify IOC evaluation in sync result")
  257. ioc_evaluated = s.get("ioc_evaluated", 0)
  258. ioc_matched = s.get("ioc_matched", 0)
  259. ioc_rejected = s.get("ioc_rejected", 0)
  260. if args.no_send:
  261. info("Send skipped — checking for any IOC evaluations this sync window")
  262. if ioc_evaluated > 0:
  263. ok(f"ioc_evaluated={ioc_evaluated} matched={ioc_matched} rejected={ioc_rejected}")
  264. else:
  265. info("No IOC events in this sync window (expected with --no-send)")
  266. elif sent_ok:
  267. if ioc_evaluated > 0:
  268. ok(f"ioc_evaluated={ioc_evaluated} matched={ioc_matched} rejected={ioc_rejected}")
  269. if ioc_matched > 0:
  270. ok(f"IOC matched — IRIS alert(s) should be created: {s['iris_alert_ids']}")
  271. else:
  272. ok(f"IOC not matched ({args.ioc_value} is clean or below threshold)")
  273. elif s.get("skipped_existing", 0) > 0:
  274. warn("Event may have been deduped from a prior run — each run uses a fresh event_id")
  275. warn("If this repeats, check that Wazuh rule actually fires: see logtest instructions")
  276. else:
  277. fail(
  278. f"ioc_evaluated=0 — event not indexed by Wazuh\n"
  279. f" Verify with: docker exec {WAZUH_CONTAINER} "
  280. f"tail -f /var/ossec/logs/alerts/alerts.log | grep {event_id}"
  281. )
  282. errors += 1
  283. # ------------------------------------------------------------------
  284. # Step 7: Verify IRIS alert was created (if matched)
  285. # ------------------------------------------------------------------
  286. step(7, "Check IRIS alerts for new IOC alert")
  287. if ioc_matched > 0:
  288. try:
  289. resp = _get(f"{INTEGRATOR_URL}/iris/alerts?per_page=10&sort_by=alert_id&sort_dir=desc")
  290. alerts_after = (resp.get("data") or {}).get("alerts", {}).get("data", [])
  291. new_alerts = [
  292. a for a in alerts_after
  293. if a["alert_id"] > max_id_before and a.get("alert_source") == "wazuh"
  294. ]
  295. if new_alerts:
  296. ok(f"Found {len(new_alerts)} new IRIS alert(s) with source=wazuh:")
  297. for a in new_alerts:
  298. print(f" alert_id={a['alert_id']} sev={a.get('alert_severity_id')} "
  299. f"title={a.get('alert_title', '')[:55]}")
  300. else:
  301. warn(f"ioc_matched={ioc_matched} but no new wazuh-sourced IRIS alerts found "
  302. f"(may already exist from a prior run)")
  303. except Exception as exc:
  304. fail(f"Could not check IRIS alerts: {exc}")
  305. errors += 1
  306. elif ioc_evaluated > 0:
  307. ok(f"IOC evaluated, not matched — {args.ioc_value} scored below thresholds (expected for test IPs)")
  308. else:
  309. info("No IRIS alert check (no IOC evaluation this run)")
  310. # ------------------------------------------------------------------
  311. # Summary
  312. # ------------------------------------------------------------------
  313. print()
  314. print("─" * 65)
  315. if errors == 0:
  316. print(f" {PASS} All checks passed")
  317. else:
  318. print(f" {FAIL} {errors} check(s) failed")
  319. print("─" * 65)
  320. print()
  321. print("Re-run options:")
  322. print(f" --ioc-type domain --ioc-value evil.example.com (DNS IOC, rule 110301)")
  323. print(f" --ioc-value 198.51.100.42 (IP IOC, rule 110318)")
  324. print(f" --no-send --minutes 60 (sync only, wider window)")
  325. print()
  326. return errors
  327. # ---------------------------------------------------------------------------
  328. # CLI
  329. # ---------------------------------------------------------------------------
  330. if __name__ == "__main__":
  331. parser = argparse.ArgumentParser(
  332. description="End-to-end IOC pipeline test: Wazuh syslog → soc-integrator → IRIS",
  333. formatter_class=argparse.RawDescriptionHelpFormatter,
  334. )
  335. parser.add_argument("--ioc-type", default="ip", choices=["ip", "domain"],
  336. help="IOC type (default: ip)")
  337. parser.add_argument("--ioc-value", default="198.51.100.42",
  338. help="IOC value embedded in syslog event (default: 198.51.100.42)")
  339. parser.add_argument("--min-severity", default="low",
  340. choices=["informational", "low", "medium", "high", "critical"],
  341. help="Min severity filter for sync (default: low)")
  342. parser.add_argument("--wait", type=int, default=20,
  343. help="Seconds to wait for Wazuh indexer (default: 20)")
  344. parser.add_argument("--minutes", type=int, default=5,
  345. help="Sync lookback window in minutes (default: 5)")
  346. parser.add_argument("--no-send", action="store_true",
  347. help="Skip sending syslog — just sync and verify")
  348. args = parser.parse_args()
  349. sys.exit(run(args))